- D02 · D03 · E02 말없이 저장 → mountGroupStep {dirty, save, discard} 공용 물음
- D04 · E05 · E06 가드 새로 · E03 = 대표 단면 별 저장 진행 중일 때만 물음
- D02 · E02~E07 세션 직접 읽기 · 쓰기 → 등록표 d02-points · e02-ground · e03-prob · e04-settings · e05-report · e06-rock · e07-settings
- 아홉 페이지 localStorage 지금 프로젝트 직접 읽기 → currentProjectId() (다른 탭이 바꿔도 이 탭 그대로)
- test_560_4 D · E 줄 넓힘(옛 beforeLeave 단언 → dirty · save) · test_401 PENDING 에서 D · E 뺌
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
13 KiB
TypeScript
375 lines
13 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`(근거 · 사용자 확정 전) · 입력 = 캐시 `e03-prob`(자동저장 없음).
|
||
* 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`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 { createCheckbox } from "@ui/ui_template_checkbox";
|
||
import { createKv } from "@ui/ui_template_kv";
|
||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||
import { createText } from "@ui/ui_template_text";
|
||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||
import { card, emptyBox, muted } from "./E03_StabSection_UI_Parts";
|
||
import { e03Table } from "./E03_StabSection_UI_Table";
|
||
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 setups = new Map<string, ProbSetup | "loading" | string>();
|
||
const records = new Map<string, ProbRecord>();
|
||
let cache: ProbCache | null = readState<ProbCache>("e03-prob", projectId);
|
||
let busy: Progress | null = null;
|
||
|
||
const save = (): void => writeState("e03-prob", cache, projectId);
|
||
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 heads = [
|
||
"E03_Pb_Use",
|
||
"E03_Pb_Var",
|
||
"E03_Pb_Mean",
|
||
"E03_Pb_Dist",
|
||
"E03_Pb_Spread",
|
||
"E03_Pb_Trunc",
|
||
];
|
||
const rows = probRows(setup);
|
||
const list = e03Table(
|
||
heads.map((k, i) => ({ label: t(k as LocaleKey), num: i === 2 })),
|
||
rows.map(inputCells),
|
||
"prob-vars",
|
||
"e03-prob-vars",
|
||
);
|
||
list.rows.forEach((tr, i) => (tr.dataset.key = rows[i].key));
|
||
return list.root;
|
||
}
|
||
|
||
function inputCells(row: ProbRow): HTMLElement[] {
|
||
const v = cache!.vars[row.key];
|
||
const use = createCheckbox("", { checked: v.use });
|
||
use.input.dataset.field = "use";
|
||
use.input.addEventListener("change", () => ((v.use = use.input.checked), save()));
|
||
const dist = createSelectField({
|
||
compact: true,
|
||
options: (row.prop === "water" ? ["normal"] : ["normal", "lognormal"]).map((d) => ({
|
||
value: d,
|
||
text: t(`E03_Pb_Dist_${d}` as LocaleKey),
|
||
})),
|
||
value: v.dist,
|
||
}).select;
|
||
dist.dataset.field = "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 = createInputField({ type: "number", compact: true, min: 0 }).input;
|
||
input.step = "0.01";
|
||
input.dataset.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)}`;
|
||
const spread = el("div", {
|
||
className: "e03-prob-spread",
|
||
children: [num(v.value, "value", (n) => (v.value = n ?? 0)), createText(` ${unit}`, "muted")],
|
||
});
|
||
return [
|
||
use.root,
|
||
el("span", { text: label }),
|
||
el("span", { text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }),
|
||
dist,
|
||
spread,
|
||
num(v.trunc, "trunc", (n) => (v.trunc = n)),
|
||
];
|
||
}
|
||
|
||
function runFields(setup: ProbSetup, slot: string, editable: boolean): HTMLElement {
|
||
const r = cache!.run;
|
||
const wrap = el("div", { className: "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;
|
||
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 = createKv(rows.map(([k, v]) => ({ label: t(k), value: v })));
|
||
dl.classList.add("e03-kpi");
|
||
dl.dataset.role = "prob-summary";
|
||
dl.querySelectorAll("dd").forEach((dd, i) => dd.setAttribute("data-k", rows[i][0]));
|
||
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, muted(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(muted(`${k} — ${v}`));
|
||
return d;
|
||
}
|
||
|
||
function render(v: ProbView): HTMLElement {
|
||
const host = card("e03-prob", "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(emptyBox(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(emptyBox(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(
|
||
muted(`${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(muted(`${busy.percent}% · ${busy.message}`, "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 };
|
||
}
|