Files
Aislo/E03_StabSection/E03_StabSection_UI_Prob.ts
T
eomsangdonandClaude Opus 5.5 210109e189 feat(E03): 「확률 · 민감도」 탭 — 분포 입력 · 기본값 후보(근거 · 사용자 확정 전) · 카드 안 진행률 · 히스토그램 · 누적분포 · 민감도 · 토네이도 SVG (537-1 · 요구 351)
- UI_Prob(새 파일): 대표 단면 · 고른 비탈 · 조건 · 단면 물성 × c · φ · γ(+ 수위선 = 지하수위 이동 · r_u 갈래 = r_u) ·
  쓰기 · 분포 · 변동계수/σ · 절단 ±kσ · 임계면 고정 / 매회 탐색 · 방법 · LHS / MC · N · 시드 · c–φ 상관 · [기본값] ·
  입력 = sessionStorage 캐시(자동저장 없음) · 계산 = 서버(잠금판 없이 카드 안 진행률)
- UI_ProbChart(새 파일): F < 1 빨강 히스토그램 · F = 1 · 기준 · 결정론 선 · 누적분포(Pf) · 민감도(범위 %) · 토네이도
- logic/E03_StabSection_Prob.ts: 변수 줄 · 기본값 후보(물성 덮어쓰기) · 요청 꼴 · 바꾼 줄 수 · 눈금
- Page: 보기 고름에 「확률 · 민감도」 · 문구 locale_e03

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 05:04:55 +09:00

385 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.
/* =============================================================================
* 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<void> => 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<T>(path: string, init?: RequestInit): Promise<T> {
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<string, ProbSetup | "loading" | string>();
const records = new Map<string, ProbRecord>();
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<void> {
setups.set(slot, "loading");
try {
const q = `side=${side}&condition=${condition}`;
const s = await api<ProbSetup>(`${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<void> {
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<Progress>(`${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" ? t("E03_Pb_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-panel__row e03-prob-run" });
const set = <K extends keyof typeof r>(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;
wrap.append(reset, go);
return wrap;
}
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 };
}