Files
Aislo/E03_StabSection/E03_StabSection_UI_Result.ts
T
eomsangdonandClaude Sonnet 5.5 79d33601f2 feat(E03): 판정 면 칸(원호 · 비원호 · 면 확인 · 모형 끝 면) + 탐색 한계 고르기 (PLAN 567-1)
- 목록 판정 면 칸 · 결과 방법 줄 · 면 확인 · 모형 끝 면 배지
- 해석 설정 탐색 한계(모형 끝 / 비탈 끝 + 2H / 비탈 끝 + 정한 m) — [해석] 이 저장 + 그 값으로 셈
- Router 설정 저장본 · 지문 · 일꾼 kwargs · 단면 · 가정 · 확률 길

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:38:55 +09:00

314 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}