/* ============================================================================= * E03_StabSection_UI_Prob.ts * E03 「확률 · 민감도」 탭(요구 351 · PLAN 537-1) — 대표 단면 · 고른 비탈 · 조건. * 분포 입력(단면 물성 × c · φ · γ · 지하수위 · r_u — 쓰기 · 분포 · 변동계수/표준편차 · 절단 ±kσ) · * 표본(LHS · MC · N · 시드) · 갈래(임계면 고정 · 매회 탐색) · 방법 · c–φ 상관 → * 서버 계산(POST …/stab/prob/{측점} · 뒤에서 · 진행 GET …/stab/prob/progress · 카드 안 진행률). * 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = sessionStorage 캐시(자동저장 없음). * 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`UI_ProbChart`). * ========================================================================== */ import "./E03_StabSection_UI_Prob.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createInputField, createSelectField, el, showToast, } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { changedCount, fillCache, probRequest, probRows, type ProbCache, type ProbRecord, type ProbRow, type ProbSetup, } from "./logic/E03_StabSection_Prob"; import type { Condition, StationRow } from "./logic/E03_StabSection_Logic"; import { cdfChart, histogramChart, sensitivityChart, tornadoChart, } from "./E03_StabSection_UI_ProbChart"; export interface ProbView { row: StationRow; condition: Condition; side: string | null; editable: boolean; } interface Progress { running: boolean; percent: number; message: string; error: string | null; } const fmt = (v: number | null | undefined, d = 3): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); const METHODS = ["bishop", "janbu", "spencer", "morgenstern_price", "ordinary"] as const; const methodName = (m: string): string => t(m === "morgenstern_price" ? "E03_Pb_Method_mp" : (`E03_Method_${m}` as LocaleKey)); async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); return body; } /** 탭 하나 — 준비(setup) · 결과 · 진행을 들고 다시 그릴 때 `repaint` 를 부름 */ export function createProbPanel(projectId: string, base: string, repaint: () => void) { const cacheKey = `e03_prob_cache:${projectId}`; const setups = new Map(); const records = new Map(); let cache: ProbCache | null = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null"); let busy: Progress | null = null; const save = (): void => sessionStorage.setItem(cacheKey, JSON.stringify(cache)); const slotOf = (v: ProbView, side: string): string => `${v.row.key}|${side}|${v.condition}`; async function load(slot: string, key: string, side: string, condition: string): Promise { setups.set(slot, "loading"); try { const q = `side=${side}&condition=${condition}`; const s = await api(`${base}/prob/setup/${encodeURIComponent(key)}?${q}`); setups.set(slot, s); if (s.last) records.set(slot, s.last); } catch (error) { setups.set(slot, error instanceof Error ? error.message : t("Common_Status_Error")); } repaint(); } async function run(slot: string, setup: ProbSetup): Promise { if (!cache) return; busy = { running: true, percent: 0, message: "", error: null }; repaint(); try { const body = probRequest(setup, cache); const path = `${base}/prob/${encodeURIComponent(setup.station_key)}`; const first = await api<{ status: string; record?: ProbRecord }>(path, { method: "POST", body: JSON.stringify(body), }); if (first.status !== "fresh") { for (;;) { await sleep(700); busy = await api(`${base}/prob/progress`); repaint(); if (!busy.running) break; } if (busy.error) throw new Error(busy.error); } busy = null; await load(slot, setup.station_key, setup.side, setup.condition); showToast(t("E03_Pb_Done"), "success"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { busy = null; repaint(); } } function inputTable(setup: ProbSetup): HTMLElement { const table = el("table", { className: "e03-table e03-prob-vars", attrs: { "data-role": "prob-vars" }, }); const heads = [ "E03_Pb_Use", "E03_Pb_Var", "E03_Pb_Mean", "E03_Pb_Dist", "E03_Pb_Spread", "E03_Pb_Trunc", ]; table.append( el("thead", { children: [el("tr", { children: heads.map((k) => el("th", { text: t(k as LocaleKey) })) })], }), ); const body = el("tbody"); for (const row of probRows(setup)) body.append(inputRow(row)); table.append(body); return table; } function inputRow(row: ProbRow): HTMLElement { const v = cache!.vars[row.key]; const tr = el("tr", { attrs: { "data-key": row.key } }); const use = el("input", { attrs: { type: "checkbox", "data-field": "use" } }); use.checked = v.use; use.addEventListener("change", () => ((v.use = use.checked), save())); const dist = el("select", { className: "ui-input ui-input--sm", attrs: { "data-field": "dist" }, }); for (const d of row.prop === "water" ? ["normal"] : ["normal", "lognormal"]) dist.append(el("option", { text: t(`E03_Pb_Dist_${d}` as LocaleKey), attrs: { value: d } })); dist.value = v.dist; dist.addEventListener("change", () => ((v.dist = dist.value as typeof v.dist), save())); const num = (value: number | null, field: string, apply: (n: number | null) => void) => { const input = el("input", { className: "ui-input ui-input--sm", attrs: { type: "number", step: "0.01", min: "0", "data-field": field }, }); input.value = value === null ? "" : String(value); input.addEventListener("input", () => { const n = Number(input.value); if (input.value.trim() === "") apply(null); else if (Number.isFinite(n) && n >= 0) apply(n); save(); }); return input; }; const unit = row.kind === "cov" ? "" : row.prop === "water" ? "σ m" : "σ"; const label = row.prop === "water" || row.prop === "ru" ? t(`E03_Pb_Prop_${row.prop}` as LocaleKey) : `${row.name} ${t(`E03_Pb_Prop_${row.prop}` as LocaleKey)}`; tr.append( el("td", { children: [use] }), el("td", { text: label }), el("td", { className: "is-num", text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }), el("td", { children: [dist] }), el("td", { children: [ num(v.value, "value", (n) => (v.value = n ?? 0)), el("span", { className: "e03-muted", text: ` ${unit}` }), ], }), el("td", { children: [num(v.trunc, "trunc", (n) => (v.trunc = n))] }), ); return tr; } function runFields(setup: ProbSetup, slot: string, editable: boolean): HTMLElement { const r = cache!.run; const wrap = el("div", { className: "e03-prob-run" }); const set = (k: K, v: (typeof r)[K]): void => { r[k] = v; save(); }; const sel = ( label: LocaleKey, value: string, opts: [string, string][], on: (v: string) => void, ) => { const f = createSelectField({ label: t(label), options: opts.map(([value, text]) => ({ value, text })), value, compact: true, onChange: on, }); f.select.dataset.field = label; return f.root; }; const numField = (label: LocaleKey, value: number, step: string, on: (n: number) => void) => { const f = createInputField({ label: t(label), type: "number", compact: true, value: String(value), }); f.input.step = step; f.input.dataset.field = label; f.input.addEventListener("input", () => { const n = Number(f.input.value); if (f.input.value.trim() !== "" && Number.isFinite(n)) on(n); }); return f.root; }; wrap.append( sel( "E03_Pb_Mode", r.mode, [ ["fixed", t("E03_Pb_Mode_fixed")], ["search", t("E03_Pb_Mode_search")], ], (v) => set("mode", v as typeof r.mode), ), sel( "E03_Pb_Method", r.method, METHODS.map((m) => [m, methodName(m)]), (v) => set("method", v as typeof r.method), ), sel( "E03_Pb_Sampling", r.sampling, [ ["lhs", "LHS"], ["mc", "Monte Carlo"], ], (v) => set("sampling", v as typeof r.sampling), ), numField("E03_Pb_N", r.n, "100", (n) => set("n", n)), numField("E03_Pb_Seed", r.seed, "1", (n) => set("seed", n)), numField("E03_Pb_Rho", r.rho, "0.1", (n) => set("rho", n)), ); const go = createButton({ label: busy ? `${t("E03_Rf_Busy")} ${busy.percent}%` : t("E03_Pb_Run"), variant: "filled", onClick: () => void run(slot, setup), }); go.dataset.role = "prob-run"; go.disabled = !editable || !!busy; const reset = createButton({ label: t("E03_Pb_Defaults"), variant: "ghost", onClick: () => { cache = fillCache(setup, null); save(); repaint(); }, }); reset.disabled = !!busy; const actions = el("div", { className: "e03-prob-actions" }); actions.append(reset, go); return el("div", { children: [wrap, actions] }); } function summary(rec: ProbRecord): HTMLElement { const s = rec.result.stats; const rows: [LocaleKey, string][] = [ ["E03_Pb_Det", fmt(rec.fs_deterministic)], ["E03_Pb_MeanF", fmt(s.mean)], ["E03_Pb_Std", fmt(s.std)], ["E03_Pb_Pf", `${fmt((s.pf ?? 0) * 100, 2)} %`], ["E03_Pb_BetaN", fmt(s.beta_normal)], ["E03_Pb_BetaLn", fmt(s.beta_lognormal)], [ "E03_Pb_PReq", s.p_below_required === null || s.p_below_required === undefined ? "-" : `${fmt(s.p_below_required * 100, 2)} %`, ], ["E03_Pb_Valid", `${s.n_valid} / ${s.n}`], ["E03_Pb_Time", `${fmt(rec.result.elapsed_s.total, 1)} s`], ]; const dl = el("dl", { className: "e03-kpi", attrs: { "data-role": "prob-summary" } }); for (const [k, v] of rows) dl.append(el("dt", { text: t(k) }), el("dd", { text: v, attrs: { "data-k": k } })); const r = rec.result; const note = `${t(`E03_Pb_Mode_${r.mode}` as LocaleKey)} · ${methodName(r.method)} · ${r.sampling.toUpperCase()} ${r.n} · seed ${r.seed} · ${rec.computed_at}`; return el("div", { children: [dl, el("p", { className: "e03-muted", text: note })] }); } function sources(setup: ProbSetup, changed: number): HTMLElement { const d = el("details", { className: "e03-prob-src" }); const tag = changed ? t("E03_Pb_UserInput").replace("{n}", String(changed)) : t("E03_Pb_Unconfirmed"); d.append(el("summary", { text: tag })); for (const [k, v] of Object.entries(setup.defaults.sources)) d.append(el("p", { className: "e03-muted", text: `${k} — ${v}` })); return d; } function render(v: ProbView): HTMLElement { const host = el("div", { className: "e03-card e03-prob", attrs: { "data-role": "prob" } }); const head = (key: LocaleKey): HTMLElement => el("h3", { className: "e03-card__title", text: t(key) }); host.append(head("E03_Tab_Prob")); if (!v.row.representative?.on) { host.append(el("p", { className: "e03-empty", text: t("E03_Pb_RepOnly") })); return host; } const side = v.side ?? v.row.sides[0]?.side; if (!side) return host; const slot = slotOf(v, side); const got = setups.get(slot); if (got === undefined) queueMicrotask(() => void load(slot, v.row.key, side, v.condition)); if (typeof got !== "object") { const text = typeof got === "string" && got !== "loading" ? got : t("E03_Loading"); host.append(el("p", { className: "e03-empty", text })); return host; } cache = fillCache(got, cache); const sideText = `${t(side === "left" ? "E03_Left" : "E03_Right")} · ${t(got.role === "cut" ? "E03_Cut" : "E03_Fill")}`; host.append( el("p", { className: "e03-muted", text: `${sideText} · ${t(`E03_Cond_${v.condition}` as LocaleKey)} · ${t("E03_Pb_MeanNote")}`, }), ); host.append( inputTable(got), runFields(got, slot, v.editable), sources(got, changedCount(got, cache)), ); if (busy) host.append( el("p", { className: "e03-muted", text: `${busy.percent}% · ${busy.message}`, attrs: { "data-role": "prob-progress" }, }), ); const rec = records.get(slot); if (!rec) return host; const res = rec.result; host.append( head("E03_Pb_Result"), summary(rec), head("E03_Pb_Hist"), histogramChart(res, rec.required, rec.fs_deterministic), head("E03_Pb_Cdf"), cdfChart(res, rec.required), head("E03_Pb_Sens"), sensitivityChart(res), head("E03_Pb_Tornado"), tornadoChart(res), ); return host; } return { render, isBusy: (): boolean => !!busy }; }