From 210109e189785bb71c0f7c9dfa197bf28bcc865a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 05:04:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(E03):=20=E3=80=8C=ED=99=95=EB=A5=A0=20?= =?UTF-8?q?=C2=B7=20=EB=AF=BC=EA=B0=90=EB=8F=84=E3=80=8D=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EB=B6=84=ED=8F=AC=20=EC=9E=85=EB=A0=A5=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=ED=9B=84=EB=B3=B4(=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=A0=84)=20=C2=B7=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=95=88=20=EC=A7=84=ED=96=89=EB=A5=A0=20=C2=B7=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EA=B7=B8=EB=9E=A8=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=EB=B6=84=ED=8F=AC=20=C2=B7=20=EB=AF=BC=EA=B0=90?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=ED=86=A0=EB=84=A4=EC=9D=B4=EB=8F=84=20SVG?= =?UTF-8?q?=20(537-1=20=C2=B7=20=EC=9A=94=EA=B5=AC=20351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- E03_StabSection/E03_StabSection_UI_Page.ts | 12 +- E03_StabSection/E03_StabSection_UI_Prob.css | 94 +++++ E03_StabSection/E03_StabSection_UI_Prob.ts | 384 ++++++++++++++++++ .../E03_StabSection_UI_ProbChart.ts | 220 ++++++++++ E03_StabSection/logic/E03_StabSection_Prob.ts | 266 ++++++++++++ ui_template/ui_template_locale_e03.ts | 60 +++ 6 files changed, 1035 insertions(+), 1 deletion(-) create mode 100644 E03_StabSection/E03_StabSection_UI_Prob.css create mode 100644 E03_StabSection/E03_StabSection_UI_Prob.ts create mode 100644 E03_StabSection/E03_StabSection_UI_ProbChart.ts create mode 100644 E03_StabSection/logic/E03_StabSection_Prob.ts diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index e9ad0a2df..e458e70f4 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -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(); @@ -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(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); @@ -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 })); diff --git a/E03_StabSection/E03_StabSection_UI_Prob.css b/E03_StabSection/E03_StabSection_UI_Prob.css new file mode 100644 index 000000000..4ea757351 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.css @@ -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); +} diff --git a/E03_StabSection/E03_StabSection_UI_Prob.ts b/E03_StabSection/E03_StabSection_UI_Prob.ts new file mode 100644 index 000000000..3cd5b777e --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.ts @@ -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 => 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" ? 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: 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 }; +} diff --git a/E03_StabSection/E03_StabSection_UI_ProbChart.ts b/E03_StabSection/E03_StabSection_UI_ProbChart.ts new file mode 100644 index 000000000..57ba2ce39 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_ProbChart.ts @@ -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); +} diff --git a/E03_StabSection/logic/E03_StabSection_Prob.ts b/E03_StabSection/logic/E03_StabSection_Prob.ts new file mode 100644 index 000000000..5d810d405 --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Prob.ts @@ -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; + rho_c_phi: number; + sources: Record; + materials?: Record>>>; +} + +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; + result: ProbResult; + computed_at: string; +} + +export interface ProbSetup { + station_key: string; + side: string; + condition: string; + role: "cut" | "fill"; + required: number | null; + fs_deterministic: Record; + 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; + 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 = { ...(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 { + 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; +} diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index d4191171f..457cf797f 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -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;