diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 54ee46eb3..b7592b7ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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): 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 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts index f6a147ffc..93a98bb67 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts @@ -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) => 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) => 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; +} diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index d1979183c..6c6b3aef8 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -175,10 +175,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { const group = target.closest("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('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" }); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 96bba9554..90a9243c5 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -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): 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(); diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 57a9df415..6e077e1d5 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -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 ): diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index 975e4948a..abee5d6a7 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -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) => { diff --git a/resources/tester/test_54_10_soil_tiers.py b/resources/tester/test_54_10_soil_tiers.py index bd116dcda..ec4310e11 100644 --- a/resources/tester/test_54_10_soil_tiers.py +++ b/resources/tester/test_54_10_soil_tiers.py @@ -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" ) diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 4c55e96a6..5a2ed80d3 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -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: diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 863d2d2a4..95d751985 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -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_측벽_읽기_줄은_세월교_박스_칸에_안_옮김():