feat(B05): BOX암거 본체 규격 프리셋·날개벽 기본 제원

- 본체 규격을 실무 관측 규격 프리셋(기본 2.0×2.0·3.0×3.0)으로 받고, 그 밖의
  규격만 "사용자 지정"으로 폭·높이를 직접 입력한다. 저장 키는 어느 쪽이든
  body_width_m·body_height_m 숫자 그대로 — 프리셋 전용 키를 만들지 않아야
  하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다
- 날개벽 유입·유출을 createWingFields 한 조각으로 묶고(기존 기슭막이
  createRevetmentFields와 같은 패턴) 설치 있음·짧은쪽 높이 1m·길이 2m·
  각도 45°를 프리필. 설치 "없음"이면 제원 칸을 접는다
- setFacility 빈값 폴백을 기본값으로 — 빈 문자열 대입으로 select가
  selectedIndex=-1(공백 표시)되던 문제도 함께 해소
- 레지스트리 정본에 위 기본값 10건 등재하고 required·phase:detail 해제.
  이 값들은 B05 서브폼이 실제로 받으므로 detail 표기가 정본과 어긋나 있었다
- addAt의 managed_by 분기에 BOX암거 한정 defaultOptions 동봉(방어선 —
  현재 우클릭 메뉴는 managed_by 타입을 제외하므로 도달하지 않는다)

기본값 전부 2026-08-17 사용자 확정. 검증: tmp/tests 101 passed·7 skipped,
npm run typecheck 무오류, prettier·ruff format 변경 없음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:36:16 +09:00
co-authored by Claude Opus 5
parent 3e9be53e7c
commit 70bfddce1b
3 changed files with 192 additions and 89 deletions
+19 -27
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재).",
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다.",
"types": [
{
"type_id": "pipe",
@@ -188,25 +188,23 @@
"label": "본체 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 2.0,
"required": false
},
{
"key": "body_height_m",
"label": "본체 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 2.0,
"required": false
},
{
"key": "wing_in",
"label": "날개벽(유입)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"default": "있음",
"required": false
},
{
@@ -214,34 +212,31 @@
"label": "유입 날개벽 짧은쪽 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 1,
"required": false
},
{
"key": "wing_in_length_m",
"label": "유입 날개벽 길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 2,
"required": false
},
{
"key": "wing_in_angle_deg",
"label": "유입 날개벽 각도",
"input": "number",
"unit": "°",
"default": null,
"required": true,
"phase": "detail"
"default": 45,
"required": false
},
{
"key": "wing_out",
"label": "날개벽(유출)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"default": "있음",
"required": false
},
{
@@ -249,27 +244,24 @@
"label": "유출 날개벽 짧은쪽 높이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 1,
"required": false
},
{
"key": "wing_out_length_m",
"label": "유출 날개벽 길이",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
"default": 2,
"required": false
},
{
"key": "wing_out_angle_deg",
"label": "유출 날개벽 각도",
"input": "number",
"unit": "°",
"default": null,
"required": true,
"phase": "detail"
"default": 45,
"required": false
}
]
},
+167 -61
View File
@@ -13,6 +13,12 @@
* 묶어 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다.
* 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로
* 자동 채운다. 형식·치수 제원은 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 type {
@@ -119,6 +125,12 @@ function numberInput(step: string, min = "0", placeholder = ""): HTMLInputElemen
return input;
}
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
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-08-17 사용자 지시 1). */
function optionalSelect(_blankLabel: string, choices: ReadonlyArray<string>): HTMLSelectElement {
@@ -310,6 +322,101 @@ export function createRevetmentFields(
};
}
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
["2.0×2.0", 2, 2],
["3.0×3.0", 3, 3],
];
const BOX_SIZE_CUSTOM = "사용자 지정";
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
interface WingKeys {
install: string;
height: string;
length: string;
angle: string;
}
const WING_IN_KEYS: WingKeys = {
install: "wing_in",
height: "wing_in_height_m",
length: "wing_in_length_m",
angle: "wing_in_angle_deg",
};
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;
inputs: Array<HTMLInputElement | HTMLSelectElement>;
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 돌붙임과 같은 패턴). */
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 heightField = labeled("짧은쪽 높이 (m)", height);
const dimsRow = grid(labeled("길이 (m)", length), labeled("각도 (°)", angle));
const box = group(title);
box.body.append(grid(labeled("설치", install), heightField), dimsRow);
function syncVisibility(): void {
const off = install.value === "없음";
heightField.hidden = off;
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
root: box.root,
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);
},
};
}
interface FacilityOptionsFormCallbacks {
/** 아무 옵션이나 바뀌었을 때 — 패널이 저장 흐름(수정 확정)을 안내하는 데 쓴다. */
onChange?: () => void;
@@ -407,28 +514,30 @@ export function createFacilityOptionsForm(
const standaloneWrap = document.createElement("div");
standaloneWrap.append(grid(standaloneRevet.formField), ...standaloneRevet.rows);
// ── BOX암거 — 본체·날개벽(유입·유출 개별) ──────────────────────────────
// ── 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 boxRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const wingIn = optionalSelect("미지정", ["있음", "없음"]);
const wingInHeight = numberInput("0.1");
const wingInLength = numberInput("0.1");
const wingInAngle = numberInput("1");
const wingOut = optionalSelect("미지정", ["있음", "없음"]);
const wingOutHeight = numberInput("0.1");
const wingOutLength = numberInput("0.1");
const wingOutAngle = numberInput("1");
const wingInGroup = group("날개벽(유입)");
wingInGroup.body.append(
grid(labeled("설치", wingIn), labeled("짧은쪽 높이 (m)", wingInHeight)),
grid(labeled("길이 (m)", wingInLength), labeled("각도 (°)", wingInAngle)),
);
const wingOutGroup = group("날개벽(유출)");
wingOutGroup.body.append(
grid(labeled("설치", wingOut), labeled("짧은쪽 높이 (m)", wingOutHeight)),
grid(labeled("길이 (m)", wingOutLength), labeled("각도 (°)", wingOutAngle)),
);
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 사용자
// 지시) 수량만 따로 받는다.
@@ -440,9 +549,9 @@ export function createFacilityOptionsForm(
inletGroup.root,
outletGroup.root,
standaloneWrap,
boxRow,
wingInGroup.root,
wingOutGroup.root,
boxWrap,
wingInFields.root,
wingOutFields.root,
fordRow,
);
@@ -459,10 +568,15 @@ export function createFacilityOptionsForm(
inletGroup.root.hidden = !isPipe;
outletGroup.root.hidden = !isPipe;
standaloneWrap.hidden = !isRevetment;
boxRow.hidden = !isBox;
wingInGroup.root.hidden = !isBox;
wingOutGroup.root.hidden = !isBox;
boxWrap.hidden = !isBox;
wingInFields.root.hidden = !isBox;
wingOutFields.root.hidden = !isBox;
fordRow.hidden = current !== "ford_bridge";
if (isBox) {
syncBoxSize();
wingInFields.syncVisibility();
wingOutFields.syncVisibility();
}
if (isPipe) {
// 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8).
const basin = inletType.value === "집수정";
@@ -489,24 +603,14 @@ export function createFacilityOptionsForm(
[
pipeMaterial,
pipeDiameter,
boxSize,
boxWidth,
boxHeight,
wingIn,
wingInHeight,
wingInLength,
wingInAngle,
wingOut,
wingOutHeight,
wingOutLength,
wingOutAngle,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
].forEach((input) => input.addEventListener("change", emit));
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));
}
return {
root,
setFacility(facility, options = {}) {
@@ -527,16 +631,27 @@ export function createFacilityOptionsForm(
inletRevet.write(options);
outletRevet.write(options);
standaloneRevet.write(options);
boxWidth.value = text("body_width_m");
boxHeight.value = text("body_height_m");
wingIn.value = text("wing_in");
wingInHeight.value = text("wing_in_height_m");
wingInLength.value = text("wing_in_length_m");
wingInAngle.value = text("wing_in_angle_deg");
wingOut.value = text("wing_out");
wingOutHeight.value = text("wing_out_height_m");
wingOutLength.value = text("wing_out_length_m");
wingOutAngle.value = text("wing_out_angle_deg");
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(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") : "";
syncVisibility();
},
@@ -557,20 +672,11 @@ export function createFacilityOptionsForm(
} else if (current === "revetment") {
standaloneRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
putNumber(options, "body_width_m", boxWidth.value);
putNumber(options, "body_height_m", boxHeight.value);
if (wingIn.value) options.wing_in = wingIn.value;
if (wingIn.value === "있음") {
putNumber(options, "wing_in_height_m", wingInHeight.value);
putNumber(options, "wing_in_length_m", wingInLength.value);
putNumber(options, "wing_in_angle_deg", wingInAngle.value);
}
if (wingOut.value) options.wing_out = wingOut.value;
if (wingOut.value === "있음") {
putNumber(options, "wing_out_height_m", wingOutHeight.value);
putNumber(options, "wing_out_length_m", wingOutLength.value);
putNumber(options, "wing_out_angle_deg", wingOutAngle.value);
}
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
@@ -664,8 +664,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
const type = typeMap().get(typeId);
if (!type) return;
// 계곡 통과 시설은 관 지점 정본으로 바로 보낸다(옵션은 추가 후 폼에서).
// BOX암거만 예외로 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을
// 함께 싣는다 — 폼을 열지 않고 넣어도 하류가 제원을 받는다(2026-08-17 사용자
// 확정). 배관·물넘이·세월교는 부속을 설계자가 고를 몫이라 빈 채로 둔다.
if (type.managed_by) {
callbacks.onPipeAdd(chainageM, { facility: typeId as PipeFacility });
const attributes: FacilityAttributes = { facility: typeId as PipeFacility };
if (typeId === "box_culvert") attributes.options = defaultOptions(type);
callbacks.onPipeAdd(chainageM, attributes);
return;
}
const placement = placementOf(typeId);