Files
Aislo/E03_StabSection/E03_StabSection_UI_Result.ts
T
eomsangdonandClaude Opus 5.5 ecd027f028 feat(532-3): E03 2D 측점 해석 길 · 화면 — 파생 저장본(측점 지문) · 진행률 · 임계면 · 등안전율 · 절편 자유물체도 · 평면 띠 · 3D 색칠
- 길: POST analyze2d(뒤에서 · 일꾼 프로세스 병렬 · 바뀐 측점만) · GET progress · result2d · section · corridor3d · PUT representative
- 입력 = E01 복사본(측점 · 횡단 · DTM 넓힘) + E02 지반 조건 · 엔진 = sub4 analyze_station · 벽 = 다음 일감(532-4)
- 화면: 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면) · 횡단(지층 · 지하수선 · 임계면 · 후보 · 등안전율 지도 · 중심 격자 · 절편) · 결과 · 절편표 · 자유물체도 · 절편력 그래프 · 평면 띠 · 3D 보기
- 계산 = logic/E03_StabSection_Logic.ts · 시험 test_532_3_e03_section.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:35:36 +09:00

286 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 { 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): 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 (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 tip = a.name.startsWith("E") || a.name.startsWith("X") || a.name === "Q" ? p0 : p1;
svg.append(
svgEl(
"text",
{ x: tip[0] + 4, y: tip[1] - 4, 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) });
root.append(head("E03_Result"), kpi(analysis), 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;
}