Files
Aislo/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts
T
eomsangdonandClaude Opus 5.5 426e574cd2 feat(B08): 수리집수면적표 우리 양식 · 공사지 지역 · 재현기간 드롭다운 · 처음 값 빈칸 · [저장](PLAN 69-4~69-6)
- 엑셀 모양(흰 종이 · 노랑 칸 · 쪽 표시) 대신 공용 카드 · 입력 · 드롭다운 · 테마 토큰 색
- 테두리는 입력 · 선택 칸만 · 굵게 + 붉은 계열은 Qd · 유출량 · 판정 · 유출율만 · 관 단면 그림 테마 색
- 지역 = 전처리 행정구역(노선 대표점) > 시군구 하나 > 프로젝트 등록 지역 · R24 = 전처리 강우량표의 재현기간별 24시간 값(없으면 입력 칸)
- 계산 때 R24 는 서버가 공사지 표로 정함 · 빈칸에 기대는 계산 칸은 「—」
- 처음 값 = 표준 값만(할증 2.0 · 관 종류 · 단면 가정 · 유출계수 0.8 · 재현기간 100년) · 측점 고유 값 빈칸
- [저장] = PUT …/quantity/hydro → B08_Quantity/quantities/hydro_inputs.json 만 · 성공 뒤 캐시 비움 · 캐시 판 올림(v2)

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

389 lines
15 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 · 유속 · 유출량 · 판정
* 테두리는 입력 · 선택 칸만(공용 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";
}
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;
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>;
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",
]);
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;
}
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,
): 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);
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 kinds = Object.entries(options.pipe_kinds).map(
([kind, n]) => [kind, `${kind} (n ${n})`] as [string, string],
);
const texts = SECTION_TEXT[inputs.section];
const nInput = numberInput("n", String(options.pipe_kinds[inputs.pipe_kind] ?? ""));
const dims = node("div", "b08-hydro__dims");
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")),
rowF("하상경사 S = 1 /", numberInput("slope_denominator", "입력"), outCell("slope_pct")),
rowF("조도계수 n", hint("비우면 관 종류 값"), nInput),
);
const figure = node("div", "b08-hydro__figure");
figure.append(pipeFigure(inputs.section), dims);
const verdict = node("div", "b08-hydro__verdict", DASH);
verdict.dataset.b08HydroVerdict = "";
const right = createCard({
title: "2. 단면 검토",
body: [
row("관 종류", select("pipe_kind", kinds)),
row(
"단면 가정",
select("section", Object.entries(options.sections) as Array<[string, string]>),
),
row("총 홍수량 Qd (㎥/s)", outCell("design_flow2", true)),
sub("유출량"),
figure,
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("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}`;
},
};
}