/* ============================================================================= * E03_StabSection_UI_Result.ts * E03 우측 결과(PLAN 532-3) — 안전율 · 기준 · 판정 · 방법 · 수렴 · 중심/반경 · 방법별 최소 안전율 · * 절편표(누르면 고름) · 절편 자유물체도(고른 절편 힘 화살) · 절편력 그래프(바닥 유효 수직응력 · * 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { forceArrows, niceTicks, pointsText, sliceShape, sliceStress, type Analysis, type Point, type SectionView, } from "./logic/E03_StabSection_Logic"; import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); const METHODS = ["ordinary", "bishop", "janbu", "spencer"] as const; export function verdictChip(ok: boolean | null): HTMLElement { const v = ok === null ? "none" : ok ? "ok" : "ng"; return el("span", { className: `e03-verdict e03-verdict--${v}`, text: t(`E03_Verdict_${v}` as LocaleKey), }); } function kpi(analysis: Analysis, walls: number): HTMLElement { const c = analysis.critical; const dl = el("dl", { className: "e03-kpi" }); const row = (label: string, value: HTMLElement | string, big = false): void => { const dd = el("dd", { className: big ? "is-big" : "" }); dd.append(value); dl.append(el("dt", { text: label }), dd); }; row(t("E03_Fs"), fmt(c?.fs, 3), true); row(t("E03_Verdict"), verdictChip(analysis.ok)); row(t("E03_Required"), fmt(analysis.required)); row( t("E03_Method"), `${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`, ); row( t("E03_Converged"), c ? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}` : "-", ); row(t("E03_Height"), `${fmt(analysis.height_m)} m`); if (walls) row(t("E03_Walls"), el("span", { text: String(walls), attrs: { "data-role": "walls" } })); if (c?.center) { row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`); row(t("E03_Radius"), `${fmt(c.radius)} m`); } return dl; } function methodTable(analysis: Analysis): HTMLElement { const table = el("table", { className: "e03-table", attrs: { "data-role": "methods" } }); const head = el("tr"); const body = el("tr"); for (const m of METHODS) { head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) })); const v = analysis.min_by_method[m]; const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m); body.append( el("td", { className: "is-num", text: unsolved ? t("E03_NoSolution") : fmt(v, 3) }), ); } table.append(el("thead", { children: [head] }), el("tbody", { children: [body] })); return table; } function sliceTable( analysis: Analysis, picked: number | null, onPick: (i: number | null) => void, ): HTMLElement { const wrap = el("div", { className: "e03-slices" }); const table = el("table", { className: "e03-table", attrs: { "data-role": "slice-table" } }); const cols: [LocaleKey, (s: Analysis["slices"][number]) => string][] = [ ["E03_Col_No", (s) => String(s.i)], ["E03_Col_Width", (s) => fmt(s.width)], ["E03_Col_Height", (s) => fmt(s.height)], ["E03_Col_Alpha", (s) => fmt(s.alpha_deg, 1)], ["E03_Col_W", (s) => fmt(s.weight + s.surcharge, 1)], ["E03_Col_U", (s) => fmt(s.pore_pressure, 1)], ["E03_Col_N", (s) => fmt(s.N_eff, 1)], ["E03_Col_S", (s) => fmt(s.S, 1)], ["E03_Col_C", (s) => fmt(s.c, 0)], ["E03_Col_Phi", (s) => fmt(s.phi_deg, 0)], ]; table.append( el("thead", { children: [ el("tr", { children: cols.map(([k]) => el("th", { className: "is-num", text: t(k) })) }), ], }), ); const body = el("tbody"); analysis.slices.forEach((s, i) => { const tr = el("tr", { className: picked === i ? "is-picked" : "", attrs: { "data-i": String(i) }, }); for (const [, f] of cols) tr.append(el("td", { className: "is-num", text: f(s) })); tr.addEventListener("click", () => onPick(picked === i ? null : i)); body.append(tr); }); table.append(body); wrap.append(table); if (picked !== null) queueMicrotask(() => body.children[picked]?.scrollIntoView({ block: "nearest" })); return wrap; } /** 자유물체도 — 고른 절편을 판에 맞춰 키우고 힘 화살(길이 = 힘 비례) · 값 글 */ function freeBody(view: SectionView, analysis: Analysis, picked: number | null): HTMLElement { const box = el("div", { className: "e03-fbd" }); const slice = picked !== null ? analysis.slices[picked] : null; if (!slice || !analysis.critical) { box.append(el("p", { className: "e03-muted", text: t("E03_PickSlice") })); return box; } const W = 380; const H = 260; const shape = sliceShape(slice, view.surface, analysis.critical.points); const xs = shape.map((p) => p[0]); const zs = shape.map((p) => p[1]); const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.01); const k = 90 / span; // 절편 = 약 90 px const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cz = (Math.min(...zs) + Math.max(...zs)) / 2; // 화면 = 횡단과 같은 방향(좌측 +offset 이 왼쪽) const scr = (p: Point): Point => [W / 2 - k * (p[0] - cx), H / 2 - k * (p[1] - cz)]; const forces = [slice.weight, slice.surcharge, slice.N_eff, slice.U, slice.S].concat( [slice.E_left, slice.E_right, slice.X_left, slice.X_right].map(Math.abs), ); const maxF = Math.max(...forces, 1e-6); const lengthPerKn = 80 / k / maxF; // 가장 큰 힘 = 80 px const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "fbd" }); const defs = svgEl("defs"); const marker = svgEl("marker", { id: "e03-head", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse", }); marker.append(svgEl("path", { d: "M0,0 L10,5 L0,10 z", fill: "context-stroke" })); defs.append(marker); svg.append(defs); svg.append( svgEl("polygon", { points: shape.map((p) => scr(p).join(",")).join(" "), class: `e03-region ${materialClass(view, slice.material ?? "")}`, }), ); for (const a of forceArrows(slice, shape, analysis.direction, lengthPerKn)) { const [p0, p1] = [scr(a.from), scr(a.to)]; svg.append( svgEl("line", { x1: p0[0], y1: p0[1], x2: p1[0], y2: p1[1], class: `e03-arrow e03-arrow--${a.name}`, "marker-end": "url(#e03-head)", "data-force": a.name, "data-value": String(a.value), }), ); // 글 = 화살 바깥 끝(들어오는 힘 = 꼬리 · 나가는 힘 = 머리)에서 화살 방향으로 조금 더 const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) || 1; const d: Point = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; const ending = a.name !== "W"; const out: Point = ending ? [-d[0], -d[1]] : d; const at: Point = ending ? p0 : p1; const anchor = out[0] > 0.3 ? "start" : out[0] < -0.3 ? "end" : "middle"; const label = { x: at[0] + out[0] * 6, y: at[1] + out[1] * 6 + (out[1] > 0.3 ? 10 : 0) }; svg.append( svgEl( "text", { ...label, "text-anchor": anchor, class: "e03-label" }, `${a.name} ${fmt(a.value, 1)}`, ), ); } box.append(svg); box.append( el("p", { className: "e03-muted", text: `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`, }), ); return box; } /** 절편력 그래프 — x = 절편 가운데(횡단과 같은 방향) · y = kPa(한 축) · 고른 절편 세로선 */ function stressGraph(analysis: Analysis, picked: number | null): HTMLElement { const box = el("div", { className: "e03-graph" }); const rows = sliceStress(analysis.slices); if (!rows.length) return box; const W = 380; const H = 200; const pad = { l: 44, r: 10, t: 10, b: 26 }; const xs = rows.map((r) => r.x); const values = rows.flatMap((r) => [r.sigma, r.u, r.tau]); const ticks = niceTicks(Math.min(...values), Math.max(...values), 4); const [y0, y1] = [ticks[0], ticks[ticks.length - 1]]; const [x0, x1] = [Math.min(...xs), Math.max(...xs)]; const sx = (x: number): number => pad.l + ((x1 - x) / Math.max(x1 - x0, 1e-6)) * (W - pad.l - pad.r); const sy = (y: number): number => H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-6)) * (H - pad.t - pad.b); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "stress-graph" }); const axis = svgEl("g", { class: "e03-axis" }); for (const v of ticks) { 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))); } axis.append(svgEl("text", { x: pad.l, y: H - 6 }, `${fmt(x1, 1)} m`)); axis.append(svgEl("text", { x: W - pad.r, y: H - 6, "text-anchor": "end" }, `${fmt(x0, 1)} m`)); axis.append(svgEl("text", { x: W / 2, y: H - 6, "text-anchor": "middle" }, t("E03_Offset"))); svg.append(axis); if (picked !== null && rows[picked]) { const x = sx(rows[picked].x); svg.append(svgEl("line", { x1: x, x2: x, y1: pad.t, y2: H - pad.b, class: "e03-radius" })); } for (const key of ["sigma", "u", "tau"] as const) { const pts: Point[] = rows.map((r) => [sx(r.x), sy(r[key])]); svg.append(svgEl("polyline", { points: pointsText(pts), class: `e03-line e03-line--${key}` })); } // 가는 기둥마다 툴팁(절편 번호 · 세 값) rows.forEach((r, i) => { const w = (W - pad.l - pad.r) / rows.length; const hit = svgEl("rect", { x: sx(r.x) - w / 2, y: pad.t, width: w, height: H - pad.t - pad.b, fill: "transparent", }); hit.append( svgEl( "title", {}, `#${i + 1} · σ' ${fmt(r.sigma, 1)} · u ${fmt(r.u, 1)} · τ ${fmt(r.tau, 1)} kPa`, ), ); svg.append(hit); }); box.append(svg); const legend = el("div", { className: "e03-legend" }); for (const [key, label] of [ ["sigma", "E03_Graph_Sigma"], ["u", "E03_Graph_U"], ["tau", "E03_Graph_Tau"], ] as const) { 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-line e03-line--${key}` })); item.append(sw as unknown as HTMLElement, t(label as LocaleKey)); legend.append(item); } box.append(legend); return box; } export function buildResult( view: SectionView, analysis: Analysis | null, picked: number | null, onPick: (i: number | null) => void, ): HTMLElement { const root = el("div", { className: "e03-result e03-card" }); if (!analysis || !analysis.critical) { root.append(el("p", { className: "e03-empty", text: t("E03_NoSurface") })); return root; } const head = (key: LocaleKey): HTMLElement => el("h3", { className: "e03-card__title", text: t(key) }); const walls = view.regions.filter((r) => r.material_id === WALL_ID).length; root.append(head("E03_Result"), kpi(analysis, walls), methodTable(analysis)); root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick)); root.append(head("E03_Fbd"), freeBody(view, analysis, picked)); root.append(head("E03_Graph"), stressGraph(analysis, picked)); return root; }