- 엑셀 모양(흰 종이 · 노랑 칸 · 쪽 표시) 대신 공용 카드 · 입력 · 드롭다운 · 테마 토큰 색 - 테두리는 입력 · 선택 칸만 · 굵게 + 붉은 계열은 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
389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* 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}`;
|
||
},
|
||
};
|
||
}
|