- 길: 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>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|