- 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>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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 };
|
||
}
|