fix(b05,b06): 월류 폭 → 높이 — 세월교 · 물넘이 한 벌 overflowSync · 안내 글 · 높이 비우면 다시 채움 (287-3)

- 까닭: 폭 → 높이 계산은 설계유량(담당 유역)이 있어야 돎 — 유량 없는 지점은 아무 일도 안 일어나고 안내도 없었음 · 높이를 비우면 안 채움
- overflowSync(design_structure_ford_calc) — 폭 바꿈 = 필요 수심 · 높이 비움 · 그 아래 = 필요 수심 · 상자 끝 안내 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」 · 유량이 오면 안내 다시 그림
- 세월교 · 물넘이 폼이 같은 것을 씀 · Facility.ts 줄 줄임(697 → 686)
- 시험 test_287 화면 길(폼 onChange 훅) — 유량 있음 · 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-12 21:24:35 +09:00
co-authored by Claude Opus 5.5
parent 74d8a071d6
commit 6c86612f65
5 changed files with 135 additions and 34 deletions
@@ -27,6 +27,54 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
}
/**
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
*/
export function overflowSync(
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
box: string,
flow: () => number | null,
emit: () => void,
) {
let pending = false;
const values = () => form().state()[box]?.[0] ?? {};
const width = () => Number(values().width ?? "");
const need = (): number | null => fordHeightM(flow(), width());
return {
arm: (fresh: boolean): void => {
pending = fresh;
},
onChange: (c: SpecContainer, item: SpecItem): void => {
if (c.id === box && item.id === "height") pending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height === "" || !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
fill: (): void => {
const want = pending && !values().height ? need() : null;
if (want === null) return form().refresh();
form().setValue(box, "height", want.toFixed(2));
pending = false;
},
/** 상자 끝 안내 글(폼 `fields` 훅). */
fields: (c: SpecContainer): HTMLElement[] => {
if (c.id !== box) return [];
const hint = document.createElement("p");
hint.className = "b05-structure__flow-hint";
hint.textContent = fordHeightHint(flow(), width());
return [hint];
},
};
}
/** 높이 빈 칸 안내. */
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM);
+21 -33
View File
@@ -56,7 +56,6 @@ import {
type SpecForm,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
@@ -65,7 +64,7 @@ import {
boxOptionsIn,
} from "../A00_Common/design/design_structure_box";
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { fordHeightM, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc";
import { overflowSync, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc";
import {
pipeDrawn,
pipeExtraCount,
@@ -372,38 +371,21 @@ export function createFacilityOptionsForm(
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
let fordFlow: number | null = null;
let heightPending = false;
const overflow = (form: () => SpecForm, box: string) => {
const values = () => form().state()[box]?.[0] ?? {};
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? ""));
return {
onChange: (c: SpecContainer, item: SpecItem) => {
if (c.id === box && item.id === "height") heightPending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height !== "" && !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 새 시설 빈 높이 = 필요 수심(사용자가 넣은 것처럼 · 알림 없음 — [추가] · [수정]이 읽음). */
fill: () => {
const want = heightPending && !values().height ? need() : null;
if (want === null) return;
form().setValue(box, "height", want.toFixed(2));
heightPending = false;
},
};
};
const fordFlowBox = overflow(() => fordForm, "ford");
const fordFlowBox = overflowSync(
() => fordForm,
"ford",
() => fordFlow,
emit,
);
const guardFields = guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
);
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
),
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
count: guardSoilCount(
() => chainage,
() => fordForm.state().outlet?.[0]?.structure,
@@ -485,9 +467,15 @@ export function createFacilityOptionsForm(
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveFlowBox = overflow(() => paveForm, "slope");
const paveFlowBox = overflowSync(
() => paveForm,
"slope",
() => fordFlow,
emit,
);
const paveForm: SpecForm = createSpecForm(specLinks, {
onChange: (c, item) => (syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)),
fields: paveFlowBox.fields,
});
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
@@ -605,7 +593,7 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
current = facility;
heightPending = fresh;
[fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh));
hiddenOptions.remember(options);
fordFlow = designFlow ?? null;
if (facility === null) {
@@ -657,3 +657,11 @@
.b05-structure__widening [aria-pressed="false"] .b05-structure__widening-area {
color: var(--color-text-secondary);
}
/* 월류 높이 안내(287-3) — 필요 수심 · 설계유량 없음 */
.b05-structure__flow-hint {
grid-column: 1 / -1;
margin: 0;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
@@ -177,7 +177,41 @@ pave.appendFordPavementOverlay(
(e) => e,
);
// 287-3 화면 길 — 폼 onChange 훅 그대로(세월교 · 물넘이 한 벌 overflowSync)
globalThis.document.createElement = (tag) => new Node(tag);
const overflowRun = (flowValue) => {
const state = { slope: [{ width: "10", height: "0.05" }] };
const form = {
state: () => state,
setValue: (c, i, v) => (state[c][0][i] = v),
refresh: () => undefined,
};
let emitted = 0;
const sync = calc.overflowSync(
() => form,
"slope",
() => flowValue,
() => (emitted += 1),
);
const edit = (item, value) => {
state.slope[0][item] = value; // 폼이 값을 적고 훅을 부름(design_structure_form onEdit)
sync.onChange({ id: "slope" }, { id: item });
return state.slope[0].height;
};
return {
afterWidth8: edit("width", "8"),
afterClear: edit("height", ""),
afterLow: edit("height", "0.01"),
afterHigh: edit("height", "0.9"),
hint: sync.fields({ id: "slope" }).map((el) => el.textContent),
otherBoxHint: sync.fields({ id: "chute" }).length,
emitted,
};
};
const out = {
overflowFlow: overflowRun(14.5776),
overflowNoFlow: overflowRun(null),
shapeOrder: layer.children.map((k) =>
(k.attrs.class ?? k.attrs["data-structure-id"]).split(" ").pop(),
),
@@ -92,7 +92,6 @@ def test_월류_폭_넣으면_높이_자동_세월교_한_벌(ts):
assert ts["heightByWidth"] and ts["heightByWidth"] > 0
assert ts["heightNoFlow"] is None
form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts")
assert 'const paveFlowBox = overflow(() => paveForm, "slope");' in form
assert 'if (facility === "ford_pavement") paveFlowBox.fill();' in form
@@ -135,3 +134,27 @@ def test_보라_손잡이는_계획고만_형상_누름은_고름만():
assert "highlightShapes(card, pickedMarkId);" in section_view
selection = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts")
assert "sectionAt.ford_pavement?.chainage_m" in selection
def test_월류_폭_높이_화면_길_유량_있음(ts):
got = ts["overflowFlow"]
assert got["afterWidth8"] == "0.26" # 폭 8 → 필요 수심(유량 14.58 ㎥/s)
assert got["afterClear"] == "0.26" # 높이 비움 → 다시 채움
assert got["afterLow"] == "0.26" # 필요 수심 아래 → 끌어올림
assert got["afterHigh"] == "0.9" # 올려 적은 값은 그대로
assert got["hint"] == ["필요 수심 0.26 이상"] and got["otherBoxHint"] == 0
assert got["emitted"] == 4
def test_월류_폭_높이_화면_길_유량_없음(ts):
got = ts["overflowNoFlow"]
assert got["afterWidth8"] == "0.05" and got["afterClear"] == "" # 손 입력 그대로
assert got["hint"] == ["설계유량 없음 — 수기 입력"]
def test_세월교_물넘이_한_벌_안내_훅():
form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts")
flat = " ".join(form.split())
assert 'overflowSync( () => fordForm, "ford", () => fordFlow, emit, )' in flat
assert 'overflowSync( () => paveForm, "slope", () => fordFlow, emit, )' in flat
assert "...fordFlowBox.fields(c)" in form and "fields: paveFlowBox.fields," in form