feat(B05): 세월교 · 박스 유출 = 「추가 흙막이(단)」 공용 스테퍼 · 「+ 흙막이」 없앰 · 집계 단 수로 상자(90-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-08 12:13:43 +09:00
co-authored by Claude Opus 5.5
parent d9bd08a236
commit 434a745ff6
9 changed files with 170 additions and 68 deletions
+23 -24
View File
@@ -40,13 +40,16 @@ import {
INLET_REVET_KEYS,
type InletStructureKind,
labeled,
numberInput,
OUTLET_REVET_KEYS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
import {
guardChoiceFields,
guardSoilCount,
outletTiersRow,
revetChoiceRow,
} from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
createSpecForm,
@@ -67,7 +70,6 @@ import {
pipeDrawn,
pipeExtraCount,
pipeLengthM,
setPipeExtraCount,
} from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -296,25 +298,11 @@ export function createFacilityOptionsForm(
return drawnMemo;
};
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
const tiersInput = numberInput("1", "0");
tiersInput.max = "9";
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
tiersRow.dataset.specItem = "outlet.tiers";
tiersRow
.querySelectorAll("button")
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
const paintTiers = (values: Record<string, string>): HTMLElement => {
const count = pipeExtraCount(chainage);
tiersInput.value = count === null ? "" : String(count);
tiersInput.disabled = count === null;
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
return tiersRow;
};
tiersInput.addEventListener("change", () => {
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
});
const tiersRow = outletTiersRow(
() => chainage,
() => pipeForm.recount(),
(structure) => !structure || structure === "기슭막이",
);
const inletChoice = revetChoiceRow(
() => chainage,
() => pipeForm.refresh(),
@@ -327,7 +315,7 @@ export function createFacilityOptionsForm(
// 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김)
fields: (c, values) =>
c.id === "outlet"
? [paintTiers(values), outletChoice.paint(values.structure !== "기슭막이")]
? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")]
: c.id === "inlet"
? [inletChoice.paint(values.structure !== "기슭막이")]
: c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값)
@@ -407,11 +395,17 @@ export function createFacilityOptionsForm(
};
};
const fordFlowBox = overflow(() => fordForm, "ford");
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
),
count: guardSoilCount(
() => chainage,
() => fordForm.state().outlet?.[0]?.structure,
),
});
@@ -473,6 +467,11 @@ export function createFacilityOptionsForm(
fields: guardChoiceFields(
() => chainage,
() => boxForm.refresh(),
() => boxForm.recount(),
),
count: guardSoilCount(
() => chainage,
() => boxForm.state().outlet?.[0]?.structure,
),
});
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
@@ -6,8 +6,11 @@
* ========================================================================== */
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
import { labeled, numberInput, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
import {
pipeExtraCount,
pipeRevetChoice,
setPipeExtraCount,
setPipeRevetChoice,
} from "../A00_Common/design/design_structure_pipe_calc";
@@ -54,20 +57,67 @@ export function revetChoiceRow(
return { paint };
}
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄.
* 명세 폼 `fields` 자리에 그대로 씀. */
/** 유출 「추가 흙막이(단)」(54-27 · 90-1 배관 · 세월교 · 박스 공용) — 값 = 횡단 조정 단 수(같은 저장 자리) ·
* 흙막이 상자 수가 따라감(폼 `count` 훅 = `pipeExtraCount`). `shown` = 유출 구조 값이 벽인지. */
export function outletTiersRow(
chainageOf: () => number | null,
/** 쓴 뒤 — 지형이 못 세운 단은 횡단 기하가 자름 · 세운 만큼으로 상자를 다시 셈. */
recount: () => void,
shown: (structure: string | undefined) => boolean,
): { paint: (values: Record<string, string>) => HTMLElement } {
const input = numberInput("1", "0");
input.max = "9";
const row = labeled("추가 흙막이(단)", stepper(input, 1, 0));
row.dataset.specItem = "outlet.tiers";
row.querySelectorAll("button").forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
input.addEventListener("change", () => {
const count = Math.max(0, Math.min(9, Math.round(Number(input.value) || 0)));
void setPipeExtraCount(chainageOf(), count).then(recount);
});
return {
paint: (values) => {
const count = pipeExtraCount(chainageOf());
input.value = count === null ? "" : String(count);
input.disabled = count === null;
row.hidden = !shown(values.structure);
return row;
},
};
}
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄 ·
* 유출 상자엔 「추가 흙막이(단)」(90-1). 명세 폼 `fields` 자리에 그대로 씀. */
export function guardChoiceFields(
chainageOf: () => number | null,
written: () => void,
recount: () => void,
): (c: { id: string }, values: Record<string, string>) => HTMLElement[] {
const sides = {
inlet: revetChoiceRow(chainageOf, written),
outlet: revetChoiceRow(chainageOf, written),
};
const tiers = outletTiersRow(chainageOf, recount, (structure) => structure === "흙막이");
return (c, values) =>
c.id === "soil_add"
? [revetChoiceRow(chainageOf, written).paint(false)]
: c.id === "inlet" || c.id === "outlet"
? [sides[c.id].paint(values.structure !== "흙막이")]
? [
...(c.id === "outlet" ? [tiers.paint(values)] : []),
sides[c.id].paint(values.structure !== "흙막이"),
]
: [];
}
/** 세월교 · 박스 흙막이 상자 수(90-1) — 유출이 흙막이면 지형이 세운 유출 단 수 · 아니면 0(옛 상자는
* 숨기고 값은 파일에 남김). 명세 폼 `count` 훅 자리. */
export function guardSoilCount(
chainageOf: () => number | null,
outletStructure: () => string | undefined,
): (c: { id: string }) => number | null | undefined {
return (c) =>
c.id !== "soil_add"
? undefined
: outletStructure() === "흙막이"
? pipeExtraCount(chainageOf())
: 0;
}
+5 -4
View File
@@ -175,10 +175,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
const group = target.closest<HTMLElement>("fieldset");
if (!group) return;
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
// 단 수 · 옵션은 부모(유출) 칸에 그대로
// 배관 · 세월교 · 박스 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10 · 90-1) — 옛 추가 기슭막이 칸
// 대신 그 상자를 켬 · 단 수 · 옵션은 부모(유출) 칸에 그대로
const soil =
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
(ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet) &&
slotUsable(ownerSlot)
? ownerSlot
.closest(".b05-structure__spec")
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
@@ -186,7 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
]
: undefined;
if (soil) {
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot);
setGroupLabel(soil, "");
soil.classList.add("is-active");
if (scroll) soil.scrollIntoView({ block: "nearest" });
@@ -142,13 +142,6 @@ export function applyPipeOptionsToCache(
});
}
}
// 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4)
const soil = body ? soilCount(patch) : null;
if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0))
// 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴)
void import("../A00_Common/design/design_structure_pipe_calc").then((calc) =>
calc.setPipeExtraCount(owner.chainage_m, soil),
);
if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole);
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
@@ -190,14 +183,6 @@ function syncBodyGuard(
return !!guard;
}
/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */
function soilCount(patch: Record<string, number | string>): number {
return Math.max(
0,
...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)),
);
}
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
const soilSent = new Map<string, { h: number | null; m: string | null }>();
+9 -2
View File
@@ -7,7 +7,8 @@
짝: `A00_Common/design/design_structure_guard.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) —
시험이 대조.
기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」.
배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11).
배관 · 세월교 · 박스 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은
파일에만(54-11 · 90-1) · 세월교 · 박스 유출이 흙막이가 아니면 0.
"""
from __future__ import annotations
@@ -30,6 +31,9 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = {
"etc": (("etc_detail", "getcdetail", "상세 내용"),),
}
_TEXT = ("structure", "form", "note")
#: 흙막이 상자 수 = 지형이 세운 유출 다단 수인 종류(54-11 · 90-1) · 세월교 · 박스 유출 구조 칸
_TIERED = ("pipe", "box_culvert", "ford_bridge")
_BODY_OUTLET = {"box_culvert": "wing_out", "ford_bridge": "outlet_type"}
def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]:
@@ -70,6 +74,9 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str
"""한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록."""
rows: list[dict[str, Any]] = []
tiers = item.get("흙막이단") # None = 모름(저장된 것 그대로)
guard_key = _BODY_OUTLET.get(item["type_id"])
if guard_key and str(item["options"].get(guard_key) or "").strip() != "흙막이":
tiers = 0 # 유출이 날개벽 · 없음 — 옛 상자는 숨김 · 값은 파일에 남김(90-1)
for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()):
head = re.compile(rf"^{prefix}_(\d+)_(.+)$")
found: dict[int, dict[str, Any]] = {}
@@ -80,7 +87,7 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str
for n in sorted(found):
if (
prefix == "soil_add"
and item["type_id"] == "pipe"
and item["type_id"] in _TIERED
and tiers is not None
and n > tiers
):
@@ -118,6 +118,8 @@ const PIPE_CALC = {
pipeDrawn: () => null,
pipeLengthM: () => null,
pipeExtraCount: () => Math.min(stored, LIMIT),
pipeRevetChoice: () => null,
setPipeRevetChoice: () => Promise.resolve(),
setPipeExtraCount: (chainage, count) => {
writes.push([chainage, count]);
stored = count;
@@ -127,11 +129,8 @@ const PIPE_CALC = {
const STUBS = {
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"../A00_Common/design/design_structure_pipe_calc": PIPE_CALC,
// 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만
"./B05_Profile_UI_Drainage_Revet_Choice": {
revetChoiceRow: () => ({ paint: () => new FakeNode("div") }),
guardChoiceFields: () => () => [],
},
// 관 보호공 [연동] · [경사] 단추(54-30) — 여기서는 자리만 · 단 칸(90-1 공용)은 실제 파일
"./B05_Profile_UI_Panel_Fields": { toggleButton: () => new FakeNode("button") },
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
buildSummaryRow: () => {
const root = new FakeNode("div");
@@ -169,7 +168,13 @@ const form = createFacilityOptionsForm();
form.setChainage(100);
form.setFacility("pipe", { pipe_diameter_mm: 1000 });
const spec = form.root.querySelector(".b05-structure__spec");
// 시설마다 명세 폼이 따로 — 지금 시설의 폼(본체 상자 id 로 찾음)
let body = null;
const specOf = () =>
form.root
.querySelectorAll(".b05-structure__spec")
.find((s) => !body || s.children.some((k) => k.dataset?.specContainer === body));
let spec = specOf();
const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id);
const tierRow = () =>
boxesOf("outlet")[0]
@@ -199,10 +204,41 @@ async function main() {
stored = 2;
form.recount();
out.fromCross = snap();
out.writes = writes;
// 다른 구조(옛 값)면 칸을 가림
form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" });
out.otherHidden = tierRow()?.hidden ?? null;
// 세월교 · 박스 유출 흙막이(90-1) — 같은 단 칸 · 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 ·
// 유출이 흙막이가 아니면 단 칸 · 옛 상자 숨김(값은 남김)
const addButton = () =>
spec.children.find((k) => k.dataset?.specAdd === "soil_add") ? true : false;
const old = { soil_add_1_form: "바자", soil_add_2_form: "돌망태", soil_add_3_form: "바자" };
for (const [facility, key, container] of [
["ford_bridge", "outlet_type", "ford"],
["box_culvert", "wing_out", "body"],
]) {
stored = 0;
body = container;
form.setFacility(facility, { [key]: "흙막이", ...old });
spec = specOf();
const steps = {};
for (const n of ["2", "0"]) {
const input = tierInput();
input.value = n;
input.dispatchEvent(new Event("change"));
await Promise.resolve();
await Promise.resolve();
steps[`set${n}`] = { ...snap(), hidden: tierRow()?.hidden ?? null, add: addButton() };
}
form.setFacility(facility, { [key]: "날개벽", ...old });
steps.wing = {
boxes: boxesOf("soil_add").length,
hidden: tierRow()?.hidden ?? null,
add: addButton(),
kept: Object.keys(form.readOptions()).filter((k) => k.startsWith("soil_add_")).length,
};
out[facility] = steps;
}
out.writes = writes;
process.stdout.write(JSON.stringify(out));
}
main().catch((error) => {
@@ -89,6 +89,8 @@ def test_다단_벽_고르기는_흙막이_컨테이너로() -> None:
dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert 'fieldset[data-spec-container="soil_add"]' in dock
assert "panelRoot.dataset.extraIndex" in dock
# 세월교 · 박스 유출 다단도 그 상자로(90-1 · 유출 자리 = 날개벽 칸)
assert "ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet" in dock
panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text(
encoding="utf-8"
)
+13 -1
View File
@@ -45,7 +45,19 @@ def test_지형_한도를_넘으면_세운_만큼(out) -> None:
def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None:
assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]]
assert out["writes"][:4] == [[100, 2], [100, 1], [100, 5], [100, 0]]
@pytest.mark.parametrize("facility", ["ford_bridge", "box_culvert"])
def test_세월교_박스_유출도_같은_단_칸(out, facility) -> None:
"""90-1 — 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · 유출이 흙막이 아니면 칸 · 옛 상자 숨김(값 남김)."""
steps = out[facility]
assert (steps["set2"]["value"], steps["set2"]["boxes"]) == ("2", 2)
assert (steps["set0"]["value"], steps["set0"]["boxes"]) == ("0", 0)
assert steps["set2"]["structures"] == ["형태", "형태"]
assert not steps["set2"]["hidden"] and not steps["set2"]["add"]
assert steps["wing"] == {"boxes": 0, "hidden": True, "add": False, "kept": 3}
assert out["writes"][4:] == [[100, 2], [100, 0]] * 2
def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None:
+22 -12
View File
@@ -134,18 +134,28 @@ def test_자동_배치_새_세월교는_기본값과_월류_높이():
assert "ford_height_m" not in blind.options # 유량 없으면 빈칸
def test_집계표_세월교_흙막이_상자_건():
item = {
"type_id": "ford_bridge",
"options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"},
"복사본": "pipe@50.0",
"흙막이단": 0, # 배관 단 수는 세월교에 안 걸림
}
rows = container_rows(item, {"gsoiladd2": "SS000001"})
assert [(r["그룹"], r["summary_item"]) for r in rows] == [
("흙막이 1", None),
("흙막이 2", "SS000001"),
]
def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
"""90-1 — 관보호공과 같은 방향(단 수 → 상자) · 유출이 흙막이가 아니면 옛 상자는 집계에서 뺌."""
soil = {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"}
def groups(type_id: str, outlet: str, tiers: int | None) -> list[tuple[str, object]]:
key = {"ford_bridge": "outlet_type", "box_culvert": "wing_out"}[type_id]
item = {
"type_id": type_id,
"options": {**soil, key: outlet},
"복사본": "pipe@50.0",
"흙막이단": tiers,
}
rows = container_rows(item, {"gsoiladd2": "SS000001"})
return [(r["그룹"], r["summary_item"]) for r in rows]
for type_id in ("ford_bridge", "box_culvert"):
both = [("흙막이 1", None), ("흙막이 2", "SS000001")]
assert groups(type_id, "흙막이", 2) == both
assert groups(type_id, "흙막이", 1) == both[:1]
assert groups(type_id, "흙막이", None) == both # 모름 = 저장된 것 그대로
assert groups(type_id, "날개벽", 2) == []
assert groups(type_id, "", None) == []
def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김():