Files
Aislo/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts
T
eomsangdonandClaude Opus 5.5 dc2c27313c feat(B08): 수리집수면적표 박스암거 사각 단면 장 · 세월교 · 물넘이 월류 높이 장(73-11 · 73-12)
- 박스 = 내공 B · H · 수심비(기본 0.8) · n(기본 0.013) · A = B·h · P = B + 2h · Manning · Qd 판정
- 세월교 · 물넘이포장 = 월류 폭(기본 10 m) · 경사(1/10) · n(0.017) → 월류 높이 자동(ford_depth)
- ford_depth = fordHeightM 파이썬 짝(두 파일 머리 · 거울 시험 test_73_11) · 관 몫 안 뺌 · 여유 없음
- [저장]에 새 칸 · 빈 장 없앰 · 물넘이포장은 관 장에서 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
2026-10-07 09:09:25 +09:00

493 lines
20 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.
/* =============================================================================
* B08_Quantity_UI_Hydro_Sheet.ts
* 홍수량 계산서 한 장(PLAN 62-2 · 69-4) — 우리 양식(공용 카드 · 입력 · 드롭다운 · 테마 색).
*
* 왼쪽 카드 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
* 오른쪽 카드 「2. 단면 검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
* 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12)
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」.
*
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
* ========================================================================== */
import { createCard, createInputField, createSelectField } from "@ui/ui_template_elements";
export interface HydroInputs {
factor: number;
runoff: number;
area_km2: number | null;
length_m: number | null;
height_m: number | null;
tc_min_minutes: number;
r24_mm: number | null;
region: string;
return_period: number;
pipe_kind: string;
n: number | null;
diameter_mm: number | null;
slope_denominator: number | null;
section: "full" | "arc240" | "eff70";
/** 장 — 측점 시설이 정함(박스 · 세월교 · 물넘이 · 관) */
sheet: "pipe" | "box" | "ford";
box_width_m?: number | null;
box_height_m?: number | null;
depth_ratio?: number | null;
overflow_width_m?: number | null;
}
export interface HydroResult {
area_ha: number | null;
r24_mm: number | null;
slope_ground: number | null;
tc_raw_hr: number | null;
tc_hr: number | null;
tc_floored: boolean;
intensity: number | null;
flow: number | null;
design_flow: number | null;
n: number;
/** 박스 수심 h · 세월교 · 물넘이 월류 높이 */
depth: number | null;
section_area: number | null;
perimeter: number | null;
radius: number | null;
slope_bed: number | null;
velocity: number | null;
capacity: number | null;
ok: boolean | null;
ratio_pct: number | null;
}
/** 공사지 — 지역(전처리 행정구역 · 프로젝트 등록) · 관측소 · 재현기간별 24시간 확률강우량 */
export interface HydroSite {
region: { name: string; full: string; source: string };
station: string;
periods: number[];
r24: Record<string, number>;
}
export interface HydroOptions {
defaults: HydroInputs;
factors: number[];
sections: Record<HydroInputs["section"], string>;
pipe_kinds: Record<string, number>;
/** 장별 처음 값(박스 n · 수심비 · 세월교 · 물넘이 n · 월류 폭 · 경사) */
sheet_defaults: Record<"box" | "ford", Partial<HydroInputs>>;
site: HydroSite;
}
export interface HydroSheet {
root: HTMLElement;
/** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */
show(result: HydroResult | null, message?: string): void;
}
type Key = keyof HydroInputs;
/** 비워 둘 수 있는 칸 — 측점 고유 값 · n(관 종류 값) · R24(공사지 자료가 없을 때 입력) */
const NULLABLE = new Set<Key>([
"area_km2",
"length_m",
"height_m",
"diameter_mm",
"slope_denominator",
"n",
"r24_mm",
"box_width_m",
"box_height_m",
"overflow_width_m",
]);
const DASH = "-";
const fmt = (value: number | null | undefined, digits: number, unit = ""): string =>
value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`;
function node<K extends keyof HTMLElementTagNameMap>(
tag: K,
className = "",
text = "",
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (className) el.className = className;
if (text) el.textContent = text;
return el;
}
/** 식 글 — `^…^` 사이는 위첨자 · `_…_` 사이는 아래첨자. */
function formula(text: string, className = "b08-hydro__formula"): HTMLElement {
const box = node("div", className);
for (const part of text.split(/(\^[^^]+\^|_[^_]+_)/)) {
if (part.startsWith("^") && part.endsWith("^")) box.append(node("sup", "", part.slice(1, -1)));
else if (part.startsWith("_") && part.endsWith("_"))
box.append(node("sub", "", part.slice(1, -1)));
else if (part) box.append(document.createTextNode(part));
}
return box;
}
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함(색은 테마 토큰 · CSS). */
function pipeFigure(section: HydroInputs["section"]): SVGSVGElement {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 200 170");
svg.setAttribute("class", "b08-hydro__pipe");
const [cx, cy, r] = [75, 85, 70];
const add = (tag: string, attrs: Record<string, string | number>, text = ""): void => {
const el = document.createElementNS(ns, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text) el.textContent = text;
svg.append(el);
};
// 물 찬 자리 — 만관 = 원 전체 · 240° = 위 120° 활꼴 뺌(수심 0.75D) · 70 % = 수심 0.626D
const level = section === "full" ? -r : section === "arc240" ? -0.5 * r : -0.252 * r;
if (section === "full") add("circle", { cx, cy, r, class: "b08-hydro__water" });
else {
const half = Math.sqrt(r * r - level * level);
const [x1, x2, y] = [cx - half, cx + half, cy + level];
add("path", { d: `M${x1} ${y} A${r} ${r} 0 1 0 ${x2} ${y} Z`, class: "b08-hydro__water" });
if (section === "arc240") {
add("path", { d: `M${x1} ${y} L${cx} ${cy} L${x2} ${y}`, class: "b08-hydro__line" });
add("text", { x: cx, y: y + 18, "text-anchor": "middle" }, "120°");
}
}
add("circle", { cx, cy, r, class: "b08-hydro__wall" });
add("circle", { cx, cy, r: r + 5, class: "b08-hydro__wall" });
// 지름 치수선
const right = cx + r + 14;
add("path", { d: `M${right} ${cy - r} L${right} ${cy + r}`, class: "b08-hydro__line" });
const depth = section === "full" ? "D" : section === "arc240" ? "0.75D" : "0.63D";
add("text", { x: right + 4, y: cy + 4 }, depth);
return svg;
}
/** 사각 단면 그림 — 박스(닫힌 내공 · 수심 h) · 월류(열린 노면 · 월류 높이 h). */
function rectFigure(closed: boolean): SVGSVGElement {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 200 170");
svg.setAttribute("class", "b08-hydro__pipe");
const add = (tag: string, attrs: Record<string, string | number>, text = ""): void => {
const el = document.createElementNS(ns, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text) el.textContent = text;
svg.append(el);
};
const [x, w, bottom] = [20, 150, 140];
const top = closed ? 40 : 110; // 박스 = 내공 높이 H · 월류 = 얕은 물
const water = closed ? bottom - 0.8 * (bottom - top) : top;
add("rect", { x, y: water, width: w, height: bottom - water, class: "b08-hydro__water" });
add("path", {
d: closed
? `M${x} ${top} L${x + w} ${top} L${x + w} ${bottom} L${x} ${bottom} Z`
: `M${x} ${top - 40} L${x} ${bottom} L${x + w} ${bottom} L${x + w} ${top - 40}`,
class: "b08-hydro__wall",
});
add("text", { x: x + w / 2, y: bottom + 18, "text-anchor": "middle" }, "B");
const right = x + w + 8;
add("path", { d: `M${right} ${water} L${right} ${bottom}`, class: "b08-hydro__line" });
add("text", { x: right + 4, y: (water + bottom) / 2 + 4 }, "h");
if (closed) {
add("path", { d: `M${x - 8} ${top} L${x - 8} ${bottom}`, class: "b08-hydro__line" });
add("text", { x: x - 8, y: top - 6, "text-anchor": "middle" }, "H");
}
return svg;
}
const SECTION_TEXT: Record<
HydroInputs["section"],
{ area: string; perimeter: string; radius: string }
> = {
full: { area: "A = π D^2^ / 4 = 0.785 D^2^", perimeter: "P = π D", radius: "R = A / P = D / 4" },
arc240: {
area: "A = π D^2^/4 × 240/360 + (D/2)^2^ × sin60 × cos60 = 0.632 D^2^",
perimeter: "P = π D × 240/360",
radius: "R = A / P",
},
eff70: {
area: "A = 0.7 × π D^2^ / 4",
perimeter: "P = π D",
radius: "R = D / 4 (만관 경심)",
},
};
export function buildHydroSheet(
stationText: string,
inputs: HydroInputs,
options: HydroOptions,
onChange: (next: HydroInputs) => void,
/** 시설 이름(박스암거 · 세월교 · 물넘이포장) — 관이면 "" */
kind = "",
): HydroSheet {
const site = options.site;
const out = new Map<string, HTMLElement>();
const outCell = (id: string, key = false): HTMLElement => {
const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH);
out.set(id, cell);
return cell;
};
let current = inputs;
const commit = (key: Key, value: unknown): void => {
current = { ...current, [key]: value } as HydroInputs;
onChange(current);
};
const numberInput = (key: Key, placeholder = ""): HTMLInputElement => {
const { input } = createInputField({ type: "number", compact: true, placeholder });
input.classList.add("b08-hydro__in");
input.step = "any";
input.dataset.b08Hydro = key;
const value = inputs[key];
input.value = value == null ? "" : String(value);
input.addEventListener("change", () => {
// 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
const parsed = input.value === "" ? null : Number(input.value);
const valid =
parsed === null
? NULLABLE.has(key)
: Number.isFinite(parsed) &&
(key === "tc_min_minutes" ? parsed >= 0 : parsed > 0) &&
(key !== "depth_ratio" || parsed <= 1);
if (valid) commit(key, parsed);
else input.value = current[key] == null ? "" : String(current[key]);
});
return input;
};
const select = (
key: Key,
choices: Array<[string, string]>,
toValue: (raw: string) => unknown = (raw) => raw,
): HTMLSelectElement => {
const { select: el } = createSelectField({
compact: true,
options: choices.map(([value, text]) => ({ value, text })),
value: String(inputs[key]),
});
el.classList.add("b08-hydro__select");
el.dataset.b08Hydro = key;
el.addEventListener("change", () => commit(key, toValue(el.value)));
return el;
};
/** 표 줄 — 이름 · 값 칸(입력 또는 계산) · 덧칸 */
const row = (label: string | HTMLElement, value: HTMLElement, extra?: HTMLElement) => {
const line = node("div", "b08-hydro__row");
const name = typeof label === "string" ? node("span", "b08-hydro__label", label) : label;
line.append(name, value);
if (extra) line.append(extra);
return line;
};
/** 식 줄 — 이름 · 식 · 값 칸 */
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement) => {
const line = node("div", "b08-hydro__row b08-hydro__row--f");
line.append(node("span", "b08-hydro__label", label), middle ?? node("span"), value);
return line;
};
const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text);
const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text);
// ── 왼쪽 1. 홍수량 계산 ──
const factor = select(
"factor",
options.factors.map((f) => [String(f), `× ${f.toFixed(1)}`]),
Number,
);
const r24Site = (period: number): number | undefined => site.r24[String(period)];
const hasRain = Object.keys(site.r24).length > 0;
const r24Value: HTMLElement = hasRain ? outCell("r24") : numberInput("r24_mm", "입력");
const periodSelect = select(
"return_period",
site.periods.map((p) => [String(p), `${p} 년`]),
Number,
);
periodSelect.addEventListener("change", () => {
// 공사지 자료 값은 바로 보임 — 계산은 서버가 같은 표로 다시 함
if (hasRain) out.get("r24")!.textContent = fmt(r24Site(current.return_period), 1);
});
const region: HTMLElement = site.region.name
? node("span", "b08-hydro__value", site.region.name)
: (() => {
const { input } = createInputField({ compact: true, placeholder: "지역 입력" });
input.classList.add("b08-hydro__in");
input.dataset.b08Hydro = "region";
input.value = inputs.region;
input.addEventListener("change", () => commit("region", input.value.trim()));
return input;
})();
if (site.region.name) region.dataset.b08Hydro = "region";
const regionNote = site.region.full
? `${site.region.full} · ${site.region.source}`
: "공사지 위치 자료 없음";
const rainNote = hasRain
? `${site.station ? `${site.station} 관측소 ` : ""}확률강우량(전처리 자료)`
: "강우 자료 없음 — 직접 입력";
const left = createCard({
title: "1. 홍수량 계산",
body: [
sub("1-1. 합리식 (Rational Method)"),
formula("Q = 1/3.6 × f × rt × A"),
row(
(() => {
const label = node("span", "b08-hydro__label b08-hydro__label--select");
label.append("Qd : 설계 홍수량 (㎥/s) = Q ", factor);
return label;
})(),
outCell("design_flow", true),
),
row("Q : 홍수량 (㎥/s)", outCell("flow")),
row("f : 유출계수", numberInput("runoff")),
row("rt : 평균 강우강도 (mm/hr)", outCell("intensity")),
row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")),
sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"),
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")),
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")),
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")),
row("s : 지표면 경사 (H / L)", outCell("slope_ground")),
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
sub("1-3. 강우강도 rt"),
formula("rt = (R_24_ / 24) × (24 / T)^0.557^"),
row("지역", region, hint(regionNote)),
row("재현기간", periodSelect),
row(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), r24Value, hint(rainNote)),
],
});
left.classList.add("b08-hydro__card");
// ── 오른쪽 2. 단면 검토 ── 장마다 단면 칸이 다름 · 유속 · 유출량 · 판정은 같음
const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet];
const nDefault = inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n;
const nInput = numberInput("n", String(nDefault ?? ""));
const nHint = inputs.sheet === "pipe" ? "비우면 관 종류 값" : `기본 ${nDefault}`;
const slopeRow = rowF(
"하상경사 S = 1 /",
numberInput("slope_denominator", "입력"),
outCell("slope_pct"),
);
const dims = node("div", "b08-hydro__dims");
const figure = node("div", "b08-hydro__figure");
let head: HTMLElement[] = [];
let title = "2. 단면 검토";
if (inputs.sheet === "box") {
title = `2. 단면 검토 — ${kind}`;
dims.append(
rowF("내공 폭 B (m)", null, numberInput("box_width_m", "입력")),
rowF("내공 높이 H (m)", null, numberInput("box_height_m", "입력")),
rowF("수심비 h / H", hint(`기본 ${sheetDefaults.depth_ratio}`), numberInput("depth_ratio")),
rowF("수심 h (m)", formula("h = 수심비 × H"), outCell("depth")),
rowF("단면적", formula("A = B × h"), outCell("section_area")),
rowF("윤변", formula("P = B + 2h"), outCell("perimeter")),
rowF("경심", formula("R = A / P"), outCell("radius")),
slopeRow,
rowF("조도계수 n", hint(nHint), nInput),
);
figure.append(rectFigure(true), dims);
} else if (inputs.sheet === "ford") {
title = `2. 월류 높이 — ${kind}`;
dims.append(
rowF(
"월류 폭 B (m)",
hint(`기본 ${sheetDefaults.overflow_width_m}`),
numberInput("overflow_width_m", "입력"),
),
slopeRow,
rowF("조도계수 n", hint(nHint), nInput),
rowF(
"월류 높이 h (m)",
formula("Q_d_ = B·h × 1/n × R^2/3^ × S^1/2^ 인 h"),
outCell("depth", true),
),
rowF("단면적", formula("A = B × h"), outCell("section_area")),
rowF("윤변", formula("P = B + 2h"), outCell("perimeter")),
rowF("경심", formula("R = A / P"), outCell("radius")),
);
figure.append(rectFigure(false), dims);
} else {
const kinds = Object.entries(options.pipe_kinds).map(
([pipeKind, n]) => [pipeKind, `${pipeKind} (n ${n})`] as [string, string],
);
const texts = SECTION_TEXT[inputs.section];
dims.append(
rowF("단면 D (mm)", null, numberInput("diameter_mm", "입력")),
rowF("단면적", formula(texts.area), outCell("section_area")),
rowF("윤변", formula(texts.perimeter), outCell("perimeter")),
rowF("경심", formula(texts.radius), outCell("radius")),
slopeRow,
rowF("조도계수 n", hint(nHint), nInput),
);
figure.append(pipeFigure(inputs.section), dims);
head = [
row("관 종류", select("pipe_kind", kinds)),
row(
"단면 가정",
select("section", Object.entries(options.sections) as Array<[string, string]>),
),
];
}
const verdict = node("div", "b08-hydro__verdict", DASH);
verdict.dataset.b08HydroVerdict = "";
const right = createCard({
title,
body: [
...head,
row("총 홍수량 Qd (㎥/s)", outCell("design_flow2", true)),
sub(inputs.sheet === "ford" ? "월류 높이 · 유출량" : "유출량"),
figure,
...(inputs.sheet === "ford"
? [hint("관 몫은 빼지 않음(월류만으로 Qd) · 여유 없음 · 0.01 m 올림")]
: []),
rowF("유속 (m/s)", formula("V = 1/n × R^2/3^ × S^1/2^"), outCell("velocity")),
rowF("유출량 (㎥/s)", formula("Q = A × V"), outCell("capacity", true)),
verdict,
rowF("유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct", true)),
],
});
right.classList.add("b08-hydro__card");
const status = node("p", "b08-hydro__status");
const sheet = node("div", "b08-hydro__sheet");
sheet.dataset.b08HydroSheet = stationText;
const body = node("div", "b08-hydro__body");
body.append(left, right);
sheet.append(body, status);
const set = (id: string, text: string): void => {
const cell = out.get(id);
if (cell) cell.textContent = text;
};
return {
root: sheet,
show(result, message = "") {
status.textContent = message;
const r = result;
set("design_flow", fmt(r?.design_flow, 2));
set("design_flow2", fmt(r?.design_flow, 2));
set("flow", fmt(r?.flow, 2));
set("intensity", fmt(r?.intensity, 2));
set("area_ha", fmt(r?.area_ha, 1, " ha"));
set("r24", fmt(r?.r24_mm ?? r24Site(current.return_period), 1));
set("tc_hr", fmt(r?.tc_hr, 3));
set(
"tc_note",
r?.tc_floored && r.tc_raw_hr != null
? `Kirpich ${fmt(r.tc_raw_hr * 60, 1)}분 → 최소 ${current.tc_min_minutes}분`
: "",
);
set("slope_ground", fmt(r?.slope_ground, 3));
set("depth", fmt(r?.depth, 2));
set("section_area", fmt(r?.section_area, 3, " ㎡"));
set("perimeter", fmt(r?.perimeter, 3, " m"));
set("radius", fmt(r?.radius, 3));
set("slope_pct", fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %"));
set("velocity", fmt(r?.velocity, 3));
set("capacity", fmt(r?.capacity, 3));
set("ratio_pct", fmt(r?.ratio_pct, 0));
const judged = r != null && r.ok != null;
verdict.classList.toggle("is-key", judged);
verdict.classList.toggle("is-ng", judged && !r.ok);
verdict.textContent = judged
? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}`
: `판정 ${DASH}`;
},
};
}