Files
Aislo/E03_StabSection/E03_StabSection_UI_ProbChart.ts
T

226 lines
7.9 KiB
TypeScript

/* =============================================================================
* 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: 22, 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: 10 }, yLabel));
svg.append(axis);
return { svg, sx, sy };
}
/** 세로 표지선 · 글 — row = 글 줄(가까운 표지끼리 안 겹치게 아래로) */
function mark(f: Frame, x: number, cls: string, label: string, row = 0): 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 + 12 * row, 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)}`, 1);
if (fsDet) mark(f, fsDet, "e03-prob-mark--det", t("E03_Pb_Det"), 2);
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)}`, 1);
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);
}