/* ============================================================================= * 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); }