/* ============================================================================= * 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; } export interface HydroOptions { defaults: HydroInputs; factors: number[]; sections: Record; pipe_kinds: Record; /** 장별 처음 값(박스 n · 수심비 · 세월교 · 물넘이 n · 월류 폭 · 경사) */ sheet_defaults: Record<"box" | "ford", Partial>; 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([ "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( 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, 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, 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(); 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}`; }, }; }