Files
Aislo/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts
T
eomsangdonandClaude Opus 5.5 5f056ec661 feat(b08): 4탭 홍수량 계산서 — 배수지점 측점 보조 탭 · 견본 모양 한 장 · 서버 계산(PLAN 62-2 · 62-3)
- 측점 보조 탭 = 저장된 pipe_points 측점만 읽음 · 입력은 이 탭 자기 기본값(B05/B06 · 배수유역 값과 안 이음)
- 1-1 합리식(Qd = Q × 할증) · 1-2 Kirpich 도달시간(최소 5분) · 1-3 rt = (R24/24)·(24/T)^0.557
- 2 단면검토 — 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · Manning 유속 · Q = A·V · OK/NG 점선 상자 · 유출율
- 선택지(할증 2.0/1.5/1.2 · 만관/240°/유효 70 % · 관 종류별 n) · 기본값 한 파일(B08_Quantity_Hydro_Defaults.py)
- 계산 = 서버 단독(B08_Quantity_Engine_Hydro · /quantity/hydro · /quantity/hydro/calc · 최신 응답만) · 입력은 캐시만 · 어떤 파일에도 안 씀
- 흰 종이 계산서(입력 빨강 · 계산 파랑 · 주요 입력 노랑) · 쪽 표시 · 시험 test_62_2_b08_hydro_engine.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
2026-10-06 18:15:29 +09:00

350 lines
14 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) — 견본(울진 2공구 수리계산서) 모양.
*
* 왼쪽 「1. 홍수량 계산」 1-1 합리식 · 1-2 도달시간(Kirpich) · 1-3 강우강도
* 오른쪽 「2. 단면검토」 관 단면 그림 · 단면적 · 윤변 · 경심 · 경사 · n · 유속 · 유출량 · 판정
* 입력값 빨강 · 계산값 파랑 · 주요 입력 노랑 바탕 · 흰 종이(어두움 테마에서도 같음).
*
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
* ========================================================================== */
export interface HydroInputs {
factor: number;
runoff: number;
area_km2: number;
length_m: number;
height_m: number;
tc_min_minutes: number;
r24_mm: number;
region: string;
return_period: number;
pipe_kind: string;
n: number | null;
diameter_mm: number;
slope_denominator: number;
section: "full" | "arc240" | "eff70";
}
export interface HydroResult {
area_ha: number;
slope_ground: number;
tc_raw_hr: number;
tc_hr: number;
tc_floored: boolean;
intensity: number;
flow: number;
design_flow: number;
n: number;
section_area: number;
perimeter: number;
radius: number;
slope_bed: number;
velocity: number;
capacity: number;
ok: boolean;
ratio_pct: number | null;
}
export interface HydroOptions {
defaults: HydroInputs;
factors: number[];
sections: Record<HydroInputs["section"], string>;
pipe_kinds: Record<string, number>;
}
export interface HydroSheet {
root: HTMLElement;
/** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */
show(result: HydroResult | null, message?: string): void;
}
type Key = keyof HydroInputs;
const fmt = (value: number | null | undefined, digits: number): string =>
value == null || !Number.isFinite(value) ? "-" : value.toFixed(digits);
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;
}
/** 관 단면 그림 — 단면 가정대로 물 찬 자리를 칠함. */
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;
}
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,
page: { index: number; total: number },
inputs: HydroInputs,
options: HydroOptions,
onChange: (next: HydroInputs) => void,
): HydroSheet {
const out = new Map<string, HTMLElement>();
const outCell = (id: string, className = ""): HTMLElement => {
const cell = node("span", `b08-hydro__out ${className}`.trim());
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, step = "any"): HTMLInputElement => {
const input = node("input", "b08-hydro__in");
input.type = "number";
input.step = step;
input.dataset.b08Hydro = key;
const value = inputs[key];
input.value = value == null ? "" : String(value);
input.addEventListener("change", () => {
// n 은 비우면 관 종류 값 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림
const parsed = input.value === "" ? null : Number(input.value);
const valid =
parsed === null
? key === "n"
: Number.isFinite(parsed) && (key === "tc_min_minutes" ? parsed >= 0 : parsed > 0);
if (valid) commit(key, parsed);
else input.value = current[key] == null ? "" : String(current[key]);
});
return input;
};
const textInput = (key: Key): HTMLInputElement => {
const input = node("input", "b08-hydro__in b08-hydro__in--text");
input.value = String(inputs[key] ?? "");
input.dataset.b08Hydro = key;
input.addEventListener("change", () => commit(key, input.value));
return input;
};
const select = (key: Key, choices: Array<[string, string]>): HTMLSelectElement => {
const el = node("select", "b08-hydro__in b08-hydro__select");
el.dataset.b08Hydro = key;
for (const [value, label] of choices) el.append(new Option(label, value));
el.value = String(inputs[key]);
el.addEventListener("change", () =>
commit(key, key === "factor" ? Number(el.value) : el.value),
);
return el;
};
/** 표 줄 — 이름 · 값 칸(입력 또는 계산) · 덧칸 */
const row = (label: string, value: HTMLElement, extra?: HTMLElement, key = false) => {
const line = node("div", `b08-hydro__row${key ? " is-key" : ""}`);
line.append(node("span", "b08-hydro__label", label), value, extra ?? node("span"));
return line;
};
/** 오른쪽 줄 — 이름 · 식(또는 입력) · 값 칸 */
const rowF = (label: string, middle: HTMLElement | null, value: HTMLElement, key = false) => {
const line = node("div", `b08-hydro__row b08-hydro__row--f${key ? " is-key" : ""}`);
line.append(node("span", "b08-hydro__label", label), middle ?? node("span"), value);
return line;
};
// ── 왼쪽 1. 홍수량 계산 ──
const left = node("section", "b08-hydro__col");
const station = node("div", "b08-hydro__station");
station.append("측점 : ", node("mark", "", stationText));
const factor = select(
"factor",
options.factors.map((f) => [String(f), f.toFixed(1)]),
);
const qdLabel = node("span", "b08-hydro__label");
qdLabel.append("Qd : 홍수량 × ", factor);
const qdRow = node("div", "b08-hydro__row");
qdRow.append(qdLabel, outCell("design_flow"));
left.append(
node("h3", "b08-hydro__h1", "1. 홍 수 량 계 산"),
station,
node("h4", "b08-hydro__h2", "1-1. Rational Method 공식"),
formula("Q = 1/3.6 × f × rt × A"),
qdRow,
row("Q : 홍수량 ㎥/sec", outCell("flow")),
row("f : 유출계수", numberInput("runoff")),
row("rt : 평균시간 강우량 mm/hr", outCell("intensity")),
row("A : 유역면적 ㎢", numberInput("area_km2"), outCell("area_ha", "b08-hydro__ha"), true),
node("h4", "b08-hydro__h2", "1-2. 홍수도달시간"),
node("p", "b08-hydro__note", "산지부 (Kirpich 공식)"),
formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"),
row("T : 홍수도달시간 (hr)", outCell("tc_hr"), outCell("tc_note", "b08-hydro__tcnote")),
row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m"), undefined, true),
row("s : 지표면경사 (H/L)", outCell("slope_ground")),
row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m"), undefined, true),
row("최소 도달시간 (분)", numberInput("tc_min_minutes")),
node("h4", "b08-hydro__h2", "1-3. 강우강도 rt의 결정"),
);
const rain = node("div", "b08-hydro__rain");
const r24 = node("div", "b08-hydro__row is-key");
r24.append(formula("R_24_ : 확률강우량 (mm/day)", "b08-hydro__label"), numberInput("r24_mm"));
const place = node("div", "b08-hydro__place");
place.append(
node("span", "b08-hydro__place-head", "지 역"),
node("span", "b08-hydro__place-head", "재현기간"),
textInput("region"),
numberInput("return_period"),
);
const rtRow = node("div", "b08-hydro__row");
rtRow.append(formula("rt = (R_24_/24) × (24/T)^0.557^"), outCell("intensity2"));
rain.append(rtRow, r24, place);
left.append(
rain,
node("p", "b08-hydro__note", "(확률강우량 — 기본값 · 지역 관측소 값으로 고침)"),
);
// ── 오른쪽 2. 단면검토 ──
const right = node("section", "b08-hydro__col");
const box = node("div", "b08-hydro__box");
const kinds = Object.entries(options.pipe_kinds).map(
([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string],
);
const kindLine = node("div", "b08-hydro__kind");
kindLine.append(
"- ",
select("pipe_kind", kinds),
" · ",
select("section", Object.entries(options.sections) as Array<[string, string]>),
);
const total = node("div", "b08-hydro__row b08-hydro__row--wide");
total.append(node("span", "b08-hydro__label", "1. 총 홍수량 Qd (㎥/s)"), outCell("design_flow2"));
const texts = SECTION_TEXT[inputs.section];
const dims = node("div", "b08-hydro__dims");
const nInput = numberInput("n");
nInput.placeholder = String(options.pipe_kinds[inputs.pipe_kind] ?? "");
dims.append(
rowF("단 면 : D (mm)", null, numberInput("diameter_mm"), true),
rowF("단면적 :", formula(texts.area), outCell("section_area")),
rowF("윤 변 :", formula(texts.perimeter), outCell("perimeter")),
rowF("경 심 :", formula(texts.radius), outCell("radius")),
rowF("하상경사 : S = 1 /", numberInput("slope_denominator"), outCell("slope_pct")),
rowF("n", node("span", "b08-hydro__hint", "비우면 관 종류 값"), nInput),
);
const figure = node("div", "b08-hydro__figure");
figure.append(pipeFigure(inputs.section), dims);
const verdict = node("div", "b08-hydro__verdict");
out.set("verdict", verdict);
box.append(
kindLine,
total,
node("div", "b08-hydro__h2", "2. 유 출 량"),
figure,
rowF("- 유 속 :", formula("V = 1/n × R^2/3^ × S^1/2^ (m/sec)"), outCell("velocity")),
rowF("- 유출량 :", formula("Q = A × V (㎥/sec)"), outCell("capacity")),
verdict,
rowF("- 유출율 (%)", formula("Q / Qd × 100"), outCell("ratio_pct")),
);
right.append(node("h3", "b08-hydro__h1", "2. 단면검토"), box);
const status = node("p", "b08-hydro__status");
const paper = node("article", "b08-hydro__paper");
paper.dataset.b08HydroSheet = stationText;
const body = node("div", "b08-hydro__body");
body.append(left, right);
paper.append(
body,
status,
node("footer", "b08-hydro__page", `— ${page.index} / ${page.total} 쪽 —`),
);
const set = (id: string, text: string): void => {
const cell = out.get(id);
if (cell) cell.textContent = text;
};
return {
root: paper,
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("intensity2", fmt(r?.intensity, 2));
set("area_ha", r ? `${fmt(r.area_ha, 1)} ha` : "-");
set("tc_hr", fmt(r?.tc_hr, 3));
set(
"tc_note",
r?.tc_floored
? `Kirpich ${fmt(r.tc_raw_hr * 60, 1)}분 → 최소 ${current.tc_min_minutes}분`
: "",
);
set("slope_ground", fmt(r?.slope_ground, 3));
set("section_area", r ? `${fmt(r.section_area, 3)} ㎡` : "-");
set("perimeter", r ? `${fmt(r.perimeter, 3)} m` : "-");
set("radius", fmt(r?.radius, 3));
set("slope_pct", r ? `${fmt(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));
verdict.classList.toggle("is-ng", !!r && !r.ok);
verdict.textContent = r
? `${fmt(r.capacity, 3)}㎥/s ${r.ok ? ">" : "<"} Qd = ${fmt(r.design_flow, 2)}㎥/s - ${r.ok ? "O K" : "N G"} -`
: "-";
},
};
}