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>
This commit is contained in:
2026-10-26 05:04:55 +09:00
co-authored by Claude Opus 5.5
parent 2bbf76a99a
commit 210109e189
6 changed files with 1035 additions and 1 deletions
+11 -1
View File
@@ -6,6 +6,7 @@
* 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) ·
* 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음).
* 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본).
* 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률).
* ========================================================================== */
import "./E03_StabSection_UI_Style.css";
@@ -40,6 +41,7 @@ import {
type WhatIfRecord,
} from "./E03_StabSection_UI_Reinforce";
import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support";
import { createProbPanel } from "./E03_StabSection_UI_Prob";
import type {
Analysis,
Condition,
@@ -100,6 +102,7 @@ export function renderE03StabSection(root: HTMLElement): void {
return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") }));
const pid: string = projectId;
const base = `/projects/${pid}/stab`;
const prob = createProbPanel(pid, base, () => paintSection());
let result: Result2d | null = null;
const sections = new Map<string, SectionView>();
@@ -110,7 +113,7 @@ export function renderE03StabSection(root: HTMLElement): void {
let side: string | null = null;
let picked: number | null = null;
const toggles: SectionToggles = { map: true, centers: false, candidates: true };
let mode: "result" | "reinforce" = "result";
let mode: "result" | "reinforce" | "prob" = "result";
let busy = false;
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
const wallRecs = new Map<string, WallRecord>();
@@ -310,6 +313,7 @@ export function renderE03StabSection(root: HTMLElement): void {
options: [
{ value: "result", text: t("E03_Tab_Result") },
{ value: "reinforce", text: t("E03_Tab_Reinforce") },
{ value: "prob", text: t("E03_Tab_Prob") },
],
value: mode,
compact: true,
@@ -336,6 +340,12 @@ export function renderE03StabSection(root: HTMLElement): void {
if (view.regions.some((r) => r.material_id === WALL_ID))
legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") }));
const row = currentRow();
if (mode === "prob" && row) {
resultHost.append(
prob.render({ row, condition, side, editable: !!result?.editable && !busy }),
);
return;
}
if (mode === "reinforce" && row) {
if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable)
queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 }));
@@ -0,0 +1,94 @@
/* E03 확률 · 민감도 탭(537-1) — 입력 표 · 히스토그램 · 민감도 선 · 토네이도 막대 */
.e03-prob-vars input[type="number"] {
width: 5.5em;
}
.e03-prob-vars select {
width: auto;
}
.e03-prob-run {
flex-wrap: wrap;
align-items: flex-end;
}
.e03-prob-src summary {
cursor: pointer;
color: var(--color-text-secondary);
}
.e03-prob-bar {
fill: var(--e03-mat-0);
fill-opacity: 0.75;
}
.e03-prob-bar--fail {
fill: var(--e03-ng);
}
.e03-prob-mark {
stroke-width: 1.5;
}
.e03-prob-mark--one {
stroke: var(--e03-ng);
}
.e03-prob-mark--req {
stroke: var(--e03-mat-3);
stroke-dasharray: 5 3;
}
.e03-prob-mark--det {
stroke: var(--e03-critical);
stroke-dasharray: 2 2;
}
.e03-prob-mark__text {
font-size: 10px;
stroke: none;
fill: var(--color-text-secondary);
}
.e03-prob-line {
fill: none;
stroke-width: 2;
}
.e03-prob-line--0 {
stroke: var(--e03-mat-0);
}
.e03-prob-line--1 {
stroke: var(--e03-mat-1);
}
.e03-prob-line--2 {
stroke: var(--e03-mat-2);
}
.e03-prob-line--3 {
stroke: var(--e03-mat-3);
}
.e03-prob-line--4 {
stroke: var(--e03-mat-4);
}
.e03-prob-line--5 {
stroke: var(--e03-mat-5);
}
.e03-prob-line--6 {
stroke: var(--e03-mat-6);
}
.e03-prob-line--7 {
stroke: var(--e03-mat-7);
}
.e03-prob-tbar--lo {
fill: var(--e03-mat-1);
}
.e03-prob-tbar--hi {
fill: var(--e03-mat-0);
}
.e03-prob-tlabel {
font-size: 10px;
fill: var(--color-text-secondary);
}
+384
View File
@@ -0,0 +1,384 @@
/* =============================================================================
* 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 };
}
@@ -0,0 +1,220 @@
/* =============================================================================
* E03_StabSection_UI_ProbChart.ts
* E03 「확률 · 민감도」 그래프 SVG(PLAN 537-1) — 안전율 히스토그램(F < 1 빨강 · F = 1 · 기준 선) ·
* 누적분포 · 민감도(가로 = 범위 % · 변수마다 선 · Slide2 Sensitivity Plot) · 토네이도(끝값 F 폭 순).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { axisTicks, type ProbCurve, type ProbResult } from "./logic/E03_StabSection_Prob";
import { pointsText, type Point } from "./logic/E03_StabSection_Logic";
import { svgEl } from "./E03_StabSection_UI_Section";
const W = 380;
const H = 200;
const PAD = { l: 44, r: 12, t: 12, b: 28 };
const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d);
interface Frame {
svg: SVGSVGElement;
sx: (x: number) => number;
sy: (y: number) => number;
}
/** 축 틀 — 가로 · 세로 눈금 · 축 이름 */
function frame(
role: string,
xr: [number, number],
yr: [number, number],
xLabel: string,
yLabel: string,
pad = PAD,
yAxis = true,
): Frame {
const xt = axisTicks(xr[0], xr[1], 5);
const yt = axisTicks(yr[0], yr[1], 4);
const [x0, x1] = [xt[0], xt[xt.length - 1]];
const [y0, y1] = [yt[0], yt[yt.length - 1]];
const sx = (x: number): number =>
pad.l + ((x - x0) / Math.max(x1 - x0, 1e-9)) * (W - pad.l - pad.r);
const sy = (y: number): number =>
H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-9)) * (H - pad.t - pad.b);
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": role }) as SVGSVGElement;
const axis = svgEl("g", { class: "e03-axis" });
for (const v of yAxis ? yt : []) {
axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) }));
axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v)));
}
for (const v of xt)
axis.append(svgEl("text", { x: sx(v), y: H - pad.b + 14, "text-anchor": "middle" }, String(v)));
axis.append(svgEl("text", { x: W - pad.r, y: H - 2, "text-anchor": "end" }, xLabel));
axis.append(svgEl("text", { x: 2, y: pad.t - 2 }, yLabel));
svg.append(axis);
return { svg, sx, sy };
}
function mark(f: Frame, x: number, cls: string, label: string): void {
f.svg.append(
svgEl("line", {
x1: f.sx(x),
x2: f.sx(x),
y1: PAD.t,
y2: H - PAD.b,
class: `e03-prob-mark ${cls}`,
}),
svgEl("text", { x: f.sx(x) + 3, y: PAD.t + 10, class: `e03-prob-mark__text ${cls}` }, label),
);
}
function box(role: string, svg: SVGSVGElement, legend?: HTMLElement): HTMLElement {
const host = el("div", { className: "e03-graph", attrs: { "data-role": `${role}-box` } });
host.append(svg as unknown as HTMLElement);
if (legend) host.append(legend);
return host;
}
/** 안전율 히스토그램 — 칸 개수 · F < 1 칸 빨강 · F = 1 · 기준 · 결정론 선 */
export function histogramChart(
res: ProbResult,
required: number | null,
fsDet: number | null,
): HTMLElement {
const h = res.stats.histogram;
if (!h) return el("div");
const { edges, counts } = h;
const xr: [number, number] = [
Math.min(edges[0], 1),
Math.max(edges[edges.length - 1], required ?? 0),
];
const f = frame("prob-hist", xr, [0, Math.max(...counts, 1)], "Fs", t("E03_Pb_Count"));
counts.forEach((n, i) => {
const [a, b] = [edges[i], edges[i + 1]];
const bar = svgEl("rect", {
x: f.sx(a),
y: f.sy(n),
width: Math.max(f.sx(b) - f.sx(a) - 0.5, 0.5),
height: f.sy(0) - f.sy(n),
class: b <= 1 ? "e03-prob-bar e03-prob-bar--fail" : "e03-prob-bar",
"data-count": n,
});
bar.append(svgEl("title", {}, `${fmt(a, 3)} ~ ${fmt(b, 3)} · ${n}`));
f.svg.append(bar);
});
mark(f, 1, "e03-prob-mark--one", "F = 1");
if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`);
if (fsDet) mark(f, fsDet, "e03-prob-mark--det", t("E03_Pb_Det"));
return box("prob-hist", f.svg);
}
/** 누적분포 — 가로 F · 세로 P(F ≤ x) · F = 1 에서 Pf */
export function cdfChart(res: ProbResult, required: number | null): HTMLElement {
const c = res.stats.cdf;
if (!c) return el("div");
const xr: [number, number] = [Math.min(c.F[0], 1), Math.max(c.F[c.F.length - 1], required ?? 0)];
const f = frame("prob-cdf", xr, [0, 1], "Fs", "P(F ≤ x)");
const pts: Point[] = c.F.map((x, i) => [f.sx(x), f.sy(c.p[i])]);
f.svg.append(
svgEl("polyline", { points: pointsText(pts), class: "e03-prob-line e03-prob-line--0" }),
);
mark(f, 1, "e03-prob-mark--one", `Pf ${fmt((res.stats.pf ?? 0) * 100, 1)} %`);
if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`);
return box("prob-cdf", f.svg);
}
function legendOf(curves: ProbCurve[]): HTMLElement {
const legend = el("div", { className: "e03-legend" });
curves.forEach((c, i) => {
const item = el("span", { className: "e03-legend__item" });
const sw = svgEl("svg", { width: 18, height: 8 });
sw.append(
svgEl("line", {
x1: 0,
x2: 18,
y1: 4,
y2: 4,
class: `e03-prob-line e03-prob-line--${i % 8}`,
}),
);
item.append(sw as unknown as HTMLElement, c.name);
legend.append(item);
});
return legend;
}
/** 민감도 — 가로 = 범위 %(0 = 아래 끝 · 100 = 위 끝) · 나머지 변수 평균 · 임계면 고정 */
export function sensitivityChart(res: ProbResult): HTMLElement {
const curves = res.sensitivity.curves;
const all = curves.flatMap((c) => c.F).filter((v): v is number => v !== null);
if (!all.length) return el("div");
const f = frame(
"prob-sens",
[0, 100],
[Math.min(...all, 1), Math.max(...all)],
t("E03_Pb_Range"),
"Fs",
);
curves.forEach((c, i) => {
const n = c.F.length;
const pts: Point[] = [];
c.F.forEach((v, k) => {
if (v !== null) pts.push([f.sx((100 * k) / Math.max(n - 1, 1)), f.sy(v)]);
});
const line = svgEl("polyline", {
points: pointsText(pts),
class: `e03-prob-line e03-prob-line--${i % 8}`,
"data-name": c.name,
});
line.append(svgEl("title", {}, `${c.name} · ${fmt(c.x[0])} ~ ${fmt(c.x[n - 1])}`));
f.svg.append(line);
});
return box("prob-sens", f.svg, legendOf(curves));
}
/** 토네이도 — 변수마다 아래 끝 · 위 끝 F 막대(평균 F 기준) · 폭 큰 순 */
export function tornadoChart(res: ProbResult): HTMLElement {
const rows = res.sensitivity.tornado;
const F0 = res.sensitivity.F_mean;
if (!rows.length || F0 === null) return el("div");
const vals = rows.flatMap((r) => [r.F_lo, r.F_hi]).filter((v): v is number => v !== null);
const pad = { ...PAD, l: 96 };
const f = frame(
"prob-tornado",
[Math.min(...vals), Math.max(...vals)],
[0, 1],
"Fs",
"",
pad,
false,
);
const step = (H - PAD.t - PAD.b) / rows.length;
rows.forEach((r, i) => {
const y = PAD.t + i * step + step * 0.15;
const hh = step * 0.7;
for (const [v, cls] of [
[r.F_lo, "e03-prob-tbar--lo"],
[r.F_hi, "e03-prob-tbar--hi"],
] as const) {
if (v === null) continue;
const [a, b] = [Math.min(f.sx(v), f.sx(F0)), Math.max(f.sx(v), f.sx(F0))];
const bar = svgEl("rect", {
x: a,
y,
width: Math.max(b - a, 0.5),
height: hh,
class: `e03-prob-tbar ${cls}`,
});
bar.append(svgEl("title", {}, `${r.name} · ${fmt(v, 3)}`));
f.svg.append(bar);
}
f.svg.append(
svgEl(
"text",
{ x: pad.l - 4, y: y + hh / 2 + 4, "text-anchor": "end", class: "e03-prob-tlabel" },
r.name,
),
);
});
mark(f, F0, "e03-prob-mark--det", fmt(F0, 3));
return box("prob-tornado", f.svg);
}
@@ -0,0 +1,266 @@
/* =============================================================================
* E03_StabSection_Prob.ts
* E03 「확률 · 민감도」 탭 계산(PLAN 537-1) — 변수 줄(단면 물성 × c · φ · γ + 지하수위 · r_u) ·
* 기본값 후보(서버 `prob_defaults.json`) · 입력 캐시 → 요청 꼴 · 그래프 눈금.
* 화면 · fetch · sessionStorage 없음(페이지가 부름).
* ========================================================================== */
export type ProbProp = "c" | "phi" | "gamma" | "water" | "ru";
export type ProbDist = "normal" | "lognormal";
export type ProbMethod = "ordinary" | "bishop" | "janbu" | "spencer" | "morgenstern_price";
export interface ProbPropDefault {
dist: ProbDist;
cov?: number;
std?: number;
trunc_sigma: number | null;
}
export interface ProbDefaults {
run: {
n: number;
sampling: "lhs" | "mc";
seed: number;
mode: "fixed" | "search";
method: ProbMethod;
};
properties: Record<ProbProp, ProbPropDefault>;
rho_c_phi: number;
sources: Record<string, string>;
materials?: Record<string, Partial<Record<ProbProp, Partial<ProbPropDefault>>>>;
}
export interface ProbMaterial {
id: string;
name: string | null;
c: number;
phi: number;
gamma_t: number;
}
export interface ProbStats {
n: number;
n_valid: number;
mean: number | null;
std: number | null;
cov?: number | null;
min?: number;
max?: number;
pf: number | null;
n_fail?: number;
beta_normal: number | null;
beta_lognormal?: number | null;
required?: number | null;
p_below_required?: number | null;
histogram?: { edges: number[]; counts: number[] };
cdf?: { p: number[]; F: number[] };
}
export interface ProbCurve {
name: string;
x: (number | null)[];
F: (number | null)[];
F_lo: number | null;
F_hi: number | null;
swing: number | null;
}
export interface ProbVariable {
name: string;
material: string | null;
prop: ProbProp;
dist: ProbDist;
mean: number;
std: number;
lo: number | null;
hi: number | null;
}
export interface ProbResult {
mode: "fixed" | "search";
method: ProbMethod;
n: number;
sampling: string;
seed: number;
variables: ProbVariable[];
stats: ProbStats;
sensitivity: {
F_mean: number | null;
curves: ProbCurve[];
tornado: { name: string; F_lo: number | null; F_hi: number | null; swing: number }[];
};
elapsed_s: { total: number; samples: number };
}
export interface ProbRecord {
station_key: string;
side: string;
condition: string;
required: number | null;
fs_deterministic: number | null;
request: Record<string, unknown>;
result: ProbResult;
computed_at: string;
}
export interface ProbSetup {
station_key: string;
side: string;
condition: string;
role: "cut" | "fill";
required: number | null;
fs_deterministic: Record<string, number | null>;
materials: ProbMaterial[];
groundwater: { mode: "none" | "line" | "ru"; ru: number | null };
defaults: ProbDefaults;
search_max_n: number;
last: ProbRecord | null;
}
/** 변수 줄 하나 — kind cov = 변동계수(평균 곱) · std = 표준편차(m · r_u) */
export interface ProbRow {
key: string;
material: string | null;
prop: ProbProp;
name: string;
mean: number;
kind: "cov" | "std";
}
export interface ProbInput {
use: boolean;
dist: ProbDist;
value: number;
trunc: number | null;
}
export interface ProbRunInput {
n: number;
sampling: "lhs" | "mc";
seed: number;
mode: "fixed" | "search";
method: ProbMethod;
rho: number;
}
export interface ProbCache {
vars: Record<string, ProbInput>;
run: ProbRunInput;
}
export const varKey = (material: string | null, prop: ProbProp): string =>
`${material ?? "*"}|${prop}`;
/** 단면 물성마다 c · φ · γ + 지하수 갈래가 수위선이면 지하수위 · r_u 면 r_u */
export function probRows(setup: ProbSetup): ProbRow[] {
const rows: ProbRow[] = [];
for (const m of setup.materials) {
const name = m.name ?? m.id;
rows.push(
{ key: varKey(m.id, "c"), material: m.id, prop: "c", name, mean: m.c, kind: "cov" },
{ key: varKey(m.id, "phi"), material: m.id, prop: "phi", name, mean: m.phi, kind: "cov" },
{
key: varKey(m.id, "gamma"),
material: m.id,
prop: "gamma",
name,
mean: m.gamma_t,
kind: "cov",
},
);
}
const gw = setup.groundwater;
if (gw.mode === "line")
rows.push({
key: varKey(null, "water"),
material: null,
prop: "water",
name: "",
mean: 0,
kind: "std",
});
if (gw.mode === "ru")
rows.push({
key: varKey(null, "ru"),
material: null,
prop: "ru",
name: "",
mean: gw.ru ?? 0,
kind: "std",
});
return rows;
}
/** 기본값 후보 — 성질 기본 위에 물성별 덮어쓰기 · 0 이면 끔 */
export function defaultInput(defaults: ProbDefaults, row: ProbRow): ProbInput {
const base = defaults.properties[row.prop];
const own = (row.material && defaults.materials?.[row.material]?.[row.prop]) || {};
const merged = { ...base, ...own };
const value = Number((row.kind === "cov" ? merged.cov : merged.std) ?? 0);
return {
use: value > 0,
dist: row.prop === "water" ? "normal" : merged.dist,
value,
trunc: merged.trunc_sigma ?? null,
};
}
/** 캐시가 없는 줄만 기본값 후보로 채움 */
export function fillCache(setup: ProbSetup, cache: ProbCache | null): ProbCache {
const d = setup.defaults;
const vars: Record<string, ProbInput> = { ...(cache?.vars ?? {}) };
for (const row of probRows(setup)) vars[row.key] ??= defaultInput(d, row);
const run = cache?.run ?? { ...d.run, rho: d.rho_c_phi };
return { vars, run };
}
/** 기본값과 다른 줄 수(「사용자 입력」 표기) */
export function changedCount(setup: ProbSetup, cache: ProbCache): number {
return probRows(setup).filter((row) => {
const a = defaultInput(setup.defaults, row);
const b = cache.vars[row.key];
return (
b && (a.use !== b.use || a.dist !== b.dist || a.value !== b.value || a.trunc !== b.trunc)
);
}).length;
}
/** POST …/stab/prob/{측점} 몸 — 켠 줄만 · 값 0 은 뺌 */
export function probRequest(setup: ProbSetup, cache: ProbCache): Record<string, unknown> {
const variables = probRows(setup)
.map((row) => ({ row, v: cache.vars[row.key] }))
.filter(({ v }) => v?.use && v.value > 0)
.map(({ row, v }) => ({
material: row.material,
prop: row.prop,
dist: v.dist,
...(row.kind === "cov" ? { cov: v.value } : { std: v.value }),
trunc_sigma: v.trunc,
}));
const r = cache.run;
return {
side: setup.side,
condition: setup.condition,
method: r.method,
mode: r.mode,
n: Math.round(r.n),
sampling: r.sampling,
seed: Math.round(r.seed),
rho_c_phi: r.rho,
variables,
};
}
/** 그래프 눈금 — 0 을 억지로 넣지 않음(안전율 축) */
export function axisTicks(min: number, max: number, count = 5): number[] {
const lo = Math.min(min, max);
const hi = Math.max(lo + 1e-9, max);
const raw = (hi - lo) / Math.max(count, 1);
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw;
const out: number[] = [];
for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step)
out.push(Math.round(v * 1e6) / 1e6);
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
return out;
}
+60
View File
@@ -211,4 +211,64 @@ export const ui_locales_e03 = {
E03_Sp_Mode_stripping: ["머리 쪽 빠짐", "Stripping"],
E03_Sp_Mode_not_crossing: ["활동면 안 만남", "Not crossing"],
E03_Sp_Mode_pending: ["결과 없음", "No result"],
/* --- 확률 · 민감도 탭(537-1) --- */
E03_Tab_Prob: ["확률 · 민감도", "Probabilistic · Sensitivity"],
E03_Pb_RepOnly: [
"대표 단면만 확률 해석합니다 — 좌측 목록에서 별(대표 단면)을 켜고 [대표 단면 저장] 뒤 보세요.",
"Probabilistic analysis runs on representative sections only — star it in the list and save first.",
],
E03_Pb_MeanNote: [
"평균 = E02 지반 조건 값(바꾸려면 E02) · 분포 · 변동계수는 기본값 후보",
"Mean = E02 ground values (change in E02) · distributions · COV are default candidates",
],
E03_Pb_Use: ["쓰기", "Use"],
E03_Pb_Var: ["변수", "Variable"],
E03_Pb_Mean: ["평균", "Mean"],
E03_Pb_Dist: ["분포", "Distribution"],
E03_Pb_Dist_normal: ["정규", "Normal"],
E03_Pb_Dist_lognormal: ["대수정규", "Lognormal"],
E03_Pb_Spread: ["변동", "Spread"],
E03_Pb_Cov: ["변동계수", "COV"],
E03_Pb_Trunc: ["절단 ±σ 배", "Truncate ±kσ"],
E03_Pb_Prop_c: ["c kPa", "c kPa"],
E03_Pb_Prop_phi: ["φ °", "φ °"],
E03_Pb_Prop_gamma: ["γ kN/m³", "γ kN/m³"],
E03_Pb_Prop_water: ["지하수위 이동 m", "Water table shift m"],
E03_Pb_Prop_ru: ["간극수압비 r_u", "Pore pressure ratio r_u"],
E03_Pb_Mode: ["임계면", "Slip surface"],
E03_Pb_Mode_fixed: ["임계면 고정(빠름)", "Fixed critical (fast)"],
E03_Pb_Mode_search: ["매회 탐색(정밀 · 느림)", "Search each sample (slow)"],
E03_Pb_Method: ["방법", "Method"],
E03_Pb_Method_mp: ["M-P(반정현)", "M-P (half-sine)"],
E03_Pb_Sampling: ["표본 방법", "Sampling"],
E03_Pb_N: ["표본 수", "Samples"],
E03_Pb_Seed: ["시드", "Seed"],
E03_Pb_Rho: ["c–φ 상관 ρ", "c–φ correlation ρ"],
E03_Pb_Run: ["확률 해석", "Run Probabilistic"],
E03_Pb_Defaults: ["기본값", "Defaults"],
E03_Pb_Done: ["확률 해석 끝", "Probabilistic analysis done"],
E03_Pb_Unconfirmed: [
"기본값 후보 · 사용자 확정 전 — 근거",
"Default candidates · not confirmed — sources",
],
E03_Pb_UserInput: ["사용자 입력 {n} 줄 · 근거", "User input {n} rows · sources"],
E03_Pb_Result: ["확률 해석 결과", "Probabilistic Result"],
E03_Pb_Det: ["결정론 Fs", "Deterministic FS"],
E03_Pb_MeanF: ["Fs 평균", "Mean FS"],
E03_Pb_Std: ["Fs 표준편차", "FS std. dev."],
E03_Pb_Pf: ["파괴확률 Pf(F < 1)", "Probability of failure"],
E03_Pb_BetaN: ["신뢰도 지수 β 정규", "Reliability index (normal)"],
E03_Pb_BetaLn: ["신뢰도 지수 β 대수정규", "Reliability index (lognormal)"],
E03_Pb_PReq: ["P(F < 기준안전율)", "P(F < required)"],
E03_Pb_Valid: ["유효 표본", "Valid samples"],
E03_Pb_Time: ["계산 시간", "Elapsed"],
E03_Pb_Hist: ["안전율 히스토그램", "FS Histogram"],
E03_Pb_Cdf: ["누적분포", "Cumulative Distribution"],
E03_Pb_Sens: [
"민감도(나머지 평균 · 임계면 고정)",
"Sensitivity (others at mean · fixed surface)",
],
E03_Pb_Tornado: ["토네이도(범위 끝 Fs)", "Tornado (FS at range ends)"],
E03_Pb_Count: ["표본 수", "Count"],
E03_Pb_Range: ["범위 %", "Percent of range"],
} as const satisfies Record<string, LocaleEntry>;