/* ============================================================================= * E03_StabSection_UI_Result.ts * E03 우측 결과(PLAN 532-3) — 안전율 · 기준 · 판정 · 방법 · 수렴 · 중심/반경 · 방법별 최소 안전율 · * 절편표(누르면 고름) · 절편 자유물체도(고른 절편 힘 화살) · 절편력 그래프(바닥 유효 수직응력 · * 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic. * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { el } from "@ui/ui_template_elements"; import { createKv, type KvRow } from "@ui/ui_template_kv"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { forceArrows, niceTicks, pointsText, sliceShape, sliceStress, type Analysis, type Point, type SectionView, type SideCase, } from "./logic/E03_StabSection_Logic"; import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; import { card, emptyBox, legend, muted } from "./E03_StabSection_UI_Parts"; import { e03Table } from "./E03_StabSection_UI_Table"; 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"; const chip = createBadge( t(`E03_Verdict_${v}` as LocaleKey), v === "ok" ? "success" : v === "ng" ? "danger" : "neutral", { kind: "lamp" }, ); chip.classList.add("e03-verdict", `e03-verdict--${v}`); return chip; } function flag(textKey: LocaleKey, hintKey: LocaleKey, role: string, hint?: string): HTMLElement { const badge = createBadge(t(textKey), "warning", { size: 18 }); badge.title = hint ?? t(hintKey); badge.dataset.role = role; return badge; } function kpi(analysis: Analysis, walls: number, basis?: SideCase): HTMLElement { const c = analysis.critical; const method = (basis?.method || "bishop") as string; const rows: KvRow[] = [ { label: t("E03_Fs"), value: fmt(c?.fs, 3), big: true }, { label: t("E03_Verdict"), value: verdictChip(analysis.ok) }, { label: t("E03_Required"), value: fmt(analysis.required) }, { label: t("E03_Method"), value: `${t(`E03_Method_${method}` as LocaleKey)} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`, }, { label: t("E03_Converged"), value: c ? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}` : "-", }, { label: t("E03_Height"), value: `${fmt(analysis.height_m)} m` }, ]; if (analysis.surface_check) { const circular = basis?.fs_circular; const hint = `${t("E03_SurfaceCheck_Hint")}${circular != null ? ` · ${t("E03_Method_bishop")} ${fmt(circular, 3)}` : ""}`; rows.push({ label: t("E03_Col_Basis"), value: flag("E03_SurfaceCheck", "E03_SurfaceCheck_Hint", "surface-check", hint), }); } if (analysis.edge) rows.push({ label: t("E03_Col_Basis"), value: flag("E03_EdgeSurface", "E03_EdgeSurface_Hint", "edge-surface"), }); if (walls) rows.push({ label: t("E03_Walls"), value: el("span", { text: String(walls), attrs: { "data-role": "walls" } }), }); if (c?.center) { rows.push({ label: t("E03_Center"), value: `${fmt(c.center[0])}, ${fmt(c.center[1])}` }); rows.push({ label: t("E03_Radius"), value: `${fmt(c.radius)} m` }); } const dl = createKv(rows); dl.classList.add("e03-kpi"); return dl; } function methodTable(analysis: Analysis): HTMLElement { return e03Table( METHODS.map((m) => ({ label: t(`E03_Method_${m}` as LocaleKey), num: true })), [ METHODS.map((m) => { const v = analysis.min_by_method[m]; const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m); return unsolved ? t("E03_NoSolution") : fmt(v, 3); }), ], "methods", ).root; } function sliceTable( analysis: Analysis, picked: number | null, onPick: (i: number | null) => void, ): HTMLElement { const wrap = el("div", { className: "e03-slices" }); 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)], ]; const slices = e03Table( cols.map(([k]) => ({ label: t(k), num: true })), analysis.slices.map((s) => cols.map(([, f]) => f(s))), "slice-table", ); slices.rows.forEach((tr, i) => { tr.dataset.i = String(i); tr.classList.toggle("is-picked", picked === i); tr.classList.toggle("is-active", picked === i); tr.addEventListener("click", () => onPick(picked === i ? null : i)); }); wrap.append(slices.root); if (picked !== null) queueMicrotask(() => slices.rows[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(muted(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( muted( `#${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); box.append( legend([ { label: t("E03_Graph_Sigma"), color: "var(--e03-mat-0)", kind: "line" }, { label: t("E03_Graph_U"), color: "var(--e03-water)", kind: "dash" }, { label: t("E03_Graph_Tau"), color: "var(--e03-mat-1)", kind: "line" }, ]), ); return box; } export function buildResult( view: SectionView, analysis: Analysis | null, picked: number | null, onPick: (i: number | null) => void, basis?: SideCase, ): HTMLElement { const root = card("e03-result"); if (!analysis || !analysis.critical) { root.append(emptyBox(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, basis), 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; }