Files
Aislo/E03_StabSection/E03_StabSection_UI_Result.ts
T
eomsangdonandClaude Opus 5.5 164024ab43 feat(532-5): E03 2D 해석에 벽(옹벽 · 석축) 연결 · 횡단 없는 측점 띠(E02 · E03)
- analyze2d 가 측점마다 벽(walls_by_station · 복사 지문으로 기억) · 그려지는 설계선을 넘김 · 벽 있는 측점만 지문에 벽 · 벽 없는 측점 = 종전 지문
- 측점 보기 = 같은 벽 모형 · 수위선 = E02 고른 지하수 갈래(station_groundwater)
- 화면 — 「반영 전」 안내 걷음 · 벽 회색 빗금 · 결과 「벽 있음」 · 목록 벽 표시
- result2d · stab/ground 에 missing_stations 칸 덧붙임 · 화면 위 띠(펼침 목록)
- E03 지반 조건 = E02 화면과 같은 upgrade(옛 정본 지하수 칸 기본값)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:19:59 +09:00

296 lines
11 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 { 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) }));
body.append(el("td", { className: "is-num", text: fmt(analysis.min_by_method[m], 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;
}