Files
Aislo/E03_StabSection/logic/E03_StabSection_Logic.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

473 lines
18 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_Logic.ts
* E03 2D 측점 해석 화면 계산(PLAN 532-3) — 화면 · three · fetch · sessionStorage 없음 · 순수 함수만.
* 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 화면은 B06 처럼 좌측(+offset)을 왼쪽에 둠
* → 화면 x = −k·x + tx · y = −k·z + ty (`fitView`). SVG 도형 좌표는 결과 좌표를 그대로 쓰고
* 묶음 transform 하나(`viewMatrix`)로 옮김 — 「SVG 좌표 = 결과 좌표」.
* 판정 = 서버 `E03_StabSection_View.overall_verdict` 와 같은 규칙(ng > ok > none).
* ========================================================================== */
export type Condition = "dry" | "wet" | "seismic";
export type Verdict = "ok" | "ng" | "none";
export type Point = [number, number];
export interface SideCase {
fs: number | null;
required: number | null;
ok: boolean | null;
kind: string | null;
converged: boolean | null;
/** 판정 면의 방법 · 원호 Bishop 안전율 · 면 확인 · 모형 끝 면 — 옛 저장본은 없음 */
method?: string | null;
fs_circular?: number | null;
surface_check?: boolean;
edge?: boolean | null;
}
export interface StationRow {
key: string;
chainage_m: number;
label: string | null;
section_mode: string | null;
ground_type: string | null;
center: [number | null, number | null] | null;
sides: { side: string; role: "cut" | "fill"; height_m: number | null }[];
cases: Partial<Record<Condition, Record<string, SideCase>>>;
min: Partial<Record<Condition, number | null>>;
verdict: Partial<Record<Condition, Verdict>>;
error: string | null;
/** 그 측점 벽(옹벽 · 석축) 수 — 옛 저장본은 없음 */
walls?: number;
representative?: { auto: string[]; user: boolean | null; on: boolean };
}
export interface Surface {
method: string;
kind: string | null;
fs: number | null;
points: Point[];
entry: Point | null;
exit: Point | null;
converged: boolean | null;
iterations: number | null;
center?: Point;
radius?: number;
}
export interface SliceRow {
i: number;
x_left: number;
x_right: number;
width: number;
height: number;
alpha_deg: number;
weight: number;
surcharge: number;
pore_pressure: number;
base_length: number;
c: number;
phi_deg: number;
material: string | null;
N: number;
N_eff: number;
U: number;
S: number;
E_left: number;
E_right: number;
X_left: number;
X_right: number;
}
export interface Analysis {
condition: Condition;
side: string;
role: "cut" | "fill";
height_m: number | null;
limits: [number, number];
direction: number;
required: number | null;
ok: boolean | null;
min_by_method: Record<string, number | null>;
surface_check?: boolean | null;
edge?: boolean | null;
/** 「해 없음」 면이 있는 방법(Spencer · M-P) — 옛 저장본은 없음 */
no_solution?: string[];
critical: Surface | null;
candidates: Surface[];
slices: SliceRow[];
grid: { method: string; x: number[]; z: number[]; fs: (number | null)[][] } | null;
}
export interface SectionView {
key: string;
chainage_m: number;
label: string | null;
materials: {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}[];
regions: { material_id: string; polygon: Point[]; holes: Point[][] }[];
surface: Point[];
bottom_z: number | null;
surcharge: { x0: number; x1: number; q: number }[];
water: Partial<Record<Condition, Point[] | null>>;
/** 조건별 지하수 꼴(E02 갈래) — r_u 면 수위선 없음 */
water_rule?: Partial<Record<Condition, { mode: string; ru: number | null }>>;
analyses: Analysis[];
}
/* ---- 판정 · 목록 ---------------------------------------------------------- */
/** 측점 판정 — 조건 하나(또는 전 조건 중 가장 나쁜 것) */
export function verdictOf(row: StationRow, condition?: Condition): Verdict {
const values = condition ? [row.verdict[condition] ?? "none"] : Object.values(row.verdict);
if (values.includes("ng")) return "ng";
return values.includes("ok") ? "ok" : "none";
}
/** 판정 면 표시 — 그 조건에서 안전율이 가장 낮은 비탈의 방법 · 면 확인 · 모형 끝 면은 비탈 하나라도 */
export interface Basis {
method: string | null;
circular: boolean | null;
surfaceCheck: boolean;
edge: boolean;
}
export function basisOf(row: StationRow, condition: Condition): Basis {
const sides = Object.values(row.cases[condition] ?? {});
const worst = sides
.filter((c) => c.fs !== null && c.method)
.sort((a, b) => (a.fs as number) - (b.fs as number))[0];
const method = worst?.method ?? null;
return {
method,
circular: method === null ? null : method === "bishop",
surfaceCheck: sides.some((c) => c.surface_check === true),
edge: sides.some((c) => c.edge === true),
};
}
export type SortMode = "chainage" | "fs";
/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */
export function listRows(
rows: StationRow[],
condition: Condition,
sort: SortMode,
ngOnly: boolean,
): StationRow[] {
const kept = ngOnly ? rows.filter((r) => verdictOf(r, condition) === "ng") : rows.slice();
if (sort === "fs") {
const fs = (r: StationRow): number => r.min[condition] ?? Number.POSITIVE_INFINITY;
kept.sort((a, b) => fs(a) - fs(b) || a.chainage_m - b.chainage_m);
} else kept.sort((a, b) => a.chainage_m - b.chainage_m);
return kept;
}
/** 깎기 · 쌓기 줄임 — 비탈 역할 좌/우(예 「쌓기 · 깎기」) */
export function roleText(row: StationRow, names: { cut: string; fill: string }): string {
const order = ["left", "right"];
return row.sides
.slice()
.sort((a, b) => order.indexOf(a.side) - order.indexOf(b.side))
.map((s) => names[s.role])
.join(" · ");
}
/** 평면 띠 — 측점 중심을 이은 선을 측점 판정으로 나눈 구간(이웃 측점 사이 중간에서 끊음) */
export function bandSegments(
rows: StationRow[],
condition: Condition | undefined,
): { verdict: Verdict; points: Point[]; keys: string[] }[] {
const pts = rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.sort((a, b) => a.chainage_m - b.chainage_m)
.map((r) => ({ row: r, p: [r.center![0]!, r.center![1]!] as Point }));
const out: { verdict: Verdict; points: Point[]; keys: string[] }[] = [];
const mid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
pts.forEach((item, i) => {
const v = verdictOf(item.row, condition);
const start = i > 0 ? mid(pts[i - 1].p, item.p) : item.p;
const end = i < pts.length - 1 ? mid(item.p, pts[i + 1].p) : item.p;
const last = out[out.length - 1];
if (last && last.verdict === v) {
last.points.push(item.p, end);
last.keys.push(item.row.key);
} else out.push({ verdict: v, points: [start, item.p, end], keys: [item.row.key] });
});
return out;
}
/** 평면 띠 보기 틀 — 북쪽 위 · 같은 축척 · 여백 pad(px) */
export function planFit(
points: Point[],
width: number,
height: number,
pad: number,
): { k: number; tx: number; ty: number } {
const xs = points.map((p) => p[0]);
const ys = points.map((p) => p[1]);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(y1 - y0, 1),
);
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
}
/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */
export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const;
export function labelStride(intervalPx: number, minPx: number): number {
return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100;
}
/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘
* (고른 측점 곁 반 걸음 안 글은 뺌 — 겹침) */
export function planLabelKeys(
rows: StationRow[],
interval: number,
stride: number,
active: string | null,
): string[] {
const step = interval > 0 ? interval : 1;
const at = rows.find((r) => r.key === active)?.chainage_m;
return rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.filter((r) => {
if (r.key === active) return true;
if (at !== undefined && Math.abs(r.chainage_m - at) < (stride * step) / 2) return false;
if (stride === 1) return true;
const n = Math.round(r.chainage_m / step);
return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0;
})
.map((r) => r.key);
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
export const RATIO_BREAKS = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5] as const;
/** 띠 번호 0~6 — 0 = 가장 위험 · 3 = 기준 바로 위 · null = 값 없음 */
export function bandIndex(fs: number | null | undefined, required: number | null): number | null {
if (fs === null || fs === undefined || !Number.isFinite(fs)) return null;
const ratio = Math.round((fs / (required && required > 0 ? required : 1)) * 1e9) / 1e9;
const i = RATIO_BREAKS.findIndex((b) => ratio < b);
return i < 0 ? RATIO_BREAKS.length : i;
}
/** 범례 — 띠마다 안전율 구간(기준안전율을 곱한 실제 값) */
export function bandLegend(
required: number | null,
): { index: number; from: number | null; to: number | null }[] {
const base = required && required > 0 ? required : 1;
const edges = RATIO_BREAKS.map((b) => Math.round(b * base * 100) / 100);
return [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({
index: i,
from: i === 0 ? null : edges[i - 1],
to: i === RATIO_BREAKS.length ? null : edges[i],
}));
}
/* ---- 횡단 보기 틀 --------------------------------------------------------- */
export interface ViewFit {
k: number; // px / m (가로 · 세로 같음 — 참 모양)
tx: number;
ty: number;
}
/** 범위(지표 · 바닥 · 격자 중심 · 임계면) 를 판에 맞춤 — 좌측(+offset) 이 왼쪽 */
export function fitView(view: SectionView, width: number, height: number, pad: number): ViewFit {
const xs: number[] = [];
const zs: number[] = [];
const add = (p: Point): void => {
xs.push(p[0]);
zs.push(p[1]);
};
view.surface.forEach(add);
if (view.bottom_z !== null) zs.push(view.bottom_z);
for (const a of view.analyses) {
if (a.grid) {
a.grid.x.forEach((x) => xs.push(x));
a.grid.z.forEach((z) => zs.push(z));
}
a.critical?.points.forEach(add);
}
const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
const k = Math.min(
(width - 2 * pad) / Math.max(x1 - x0, 1),
(height - 2 * pad) / Math.max(z1 - z0, 1),
);
return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 };
}
/**
* 처음 보기(줌 · 팬) — 그 비탈 탐색 범위 · 임계면 · 격자 중심을 판 가운데 크게(여백 25 %).
* 판 전체(scale 1) = 모형 전체 · 두 번 누름 = 이 보기 · 배율은 1~maxScale.
*/
export function focusHome(
fit: ViewFit,
view: SectionView,
analysis: Analysis | null,
width: number,
height: number,
maxScale: number,
): { scale: number; tx: number; ty: number } {
if (!analysis) return { scale: 1, tx: 0, ty: 0 };
const pts: Point[] = [];
const [x0, x1] = analysis.limits;
view.surface.filter((p) => p[0] >= x0 && p[0] <= x1).forEach((p) => pts.push(p));
analysis.critical?.points.forEach((p) => pts.push(p));
if (analysis.grid) {
const g = analysis.grid;
pts.push([Math.min(...g.x), Math.min(...g.z)], [Math.max(...g.x), Math.max(...g.z)]);
}
if (!pts.length) return { scale: 1, tx: 0, ty: 0 };
const scr = pts.map((p) => toScreen(fit, p));
const [sx0, sx1] = [Math.min(...scr.map((p) => p[0])), Math.max(...scr.map((p) => p[0]))];
const [sy0, sy1] = [Math.min(...scr.map((p) => p[1])), Math.max(...scr.map((p) => p[1]))];
const raw = Math.min(
width / Math.max((sx1 - sx0) * 1.25, 1),
height / Math.max((sy1 - sy0) * 1.25, 1),
);
const scale = Math.min(Math.max(raw, 1), maxScale);
return {
scale,
tx: width / 2 - (scale * (sx0 + sx1)) / 2,
ty: height / 2 - (scale * (sy0 + sy1)) / 2,
};
}
/** 결과 좌표 → 화면 px */
export function toScreen(fit: ViewFit, p: Point): Point {
return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
}
/** 화면 px → 결과 좌표(마우스 아래 값) */
export function toModel(fit: ViewFit, p: Point): Point {
return [(fit.tx - p[0]) / fit.k, (fit.ty - p[1]) / fit.k];
}
/** 결과 좌표 도형 묶음에 거는 transform — 도형 points 는 결과 좌표 그대로 */
export function viewMatrix(fit: ViewFit): string {
return `matrix(${-fit.k} 0 0 ${-fit.k} ${fit.tx} ${fit.ty})`;
}
/** 폴리곤 · 선 → SVG points 글(결과 좌표 그대로) */
export function pointsText(points: Point[]): string {
return points.map((p) => `${p[0]},${p[1]}`).join(" ");
}
/** 꺾은선 위 x 의 z(선형 · 범위 밖은 끝값) — 선은 x 오름 · 내림 무관 */
export function zAt(line: Point[], x: number): number | null {
if (!line.length) return null;
const pts = line.slice().sort((a, b) => a[0] - b[0]);
if (x <= pts[0][0]) return pts[0][1];
for (let i = 1; i < pts.length; i += 1) {
if (x <= pts[i][0]) {
const [a, b] = [pts[i - 1], pts[i]];
const t = b[0] === a[0] ? 0 : (x - a[0]) / (b[0] - a[0]);
return a[1] + t * (b[1] - a[1]);
}
}
return pts[pts.length - 1][1];
}
/** 절편 네 꼭짓점 — 바닥 = 임계면 · 위 = 지표 */
export function sliceShape(slice: SliceRow, surface: Point[], base: Point[]): Point[] {
const at = (line: Point[], x: number): number => zAt(line, x) ?? 0;
return [
[slice.x_left, at(base, slice.x_left)],
[slice.x_right, at(base, slice.x_right)],
[slice.x_right, at(surface, slice.x_right)],
[slice.x_left, at(surface, slice.x_left)],
];
}
/* ---- 자유물체도 --------------------------------------------------------- */
export interface ForceArrow {
name: "W" | "Q" | "N" | "U" | "S" | "EL" | "ER" | "XL" | "XR";
value: number;
/** 화살 시작 · 끝(결과 좌표 · 길이 = 값 × scale) */
from: Point;
to: Point;
}
/**
* 절편 하나 힘 화살 — W · Q 아래 · N · U 바닥 법선(절편 안쪽) · S 바닥 따라 미끄럼 반대 ·
* E 좌우 면 수평 · X 좌우 면 연직. 엔진 절편표는 활동 방향(direction)으로 뒤집은 틀의 차례라
* 그 틀의 「왼쪽 면」은 direction < 0 이면 결과 좌표의 오른쪽 면. 길이 = 값 × lengthPerKn.
*/
export function forceArrows(
slice: SliceRow,
shape: Point[],
direction: number,
lengthPerKn: number,
): ForceArrow[] {
const s = direction < 0 ? -1 : 1;
const [bl, br, tr, tl] = shape;
const midBase: Point = [(bl[0] + br[0]) / 2, (bl[1] + br[1]) / 2];
const midTop: Point = [(tl[0] + tr[0]) / 2, (tl[1] + tr[1]) / 2];
const centroid: Point = [(midBase[0] + midTop[0]) / 2, (midBase[1] + midTop[1]) / 2];
const len = Math.hypot(br[0] - bl[0], br[1] - bl[1]) || 1;
const t: Point = [(br[0] - bl[0]) / len, (br[1] - bl[1]) / len]; // 바닥 왼→오
const n: Point = [-t[1], t[0]]; // 바닥 법선 위쪽
const faceMid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const [nearFace, farFace] =
s > 0 ? [faceMid(bl, tl), faceMid(br, tr)] : [faceMid(br, tr), faceMid(bl, tl)];
const arrow = (
name: ForceArrow["name"],
value: number,
at: Point,
dir: Point,
ending = false,
): ForceArrow => {
const d: Point = [dir[0] * value * lengthPerKn, dir[1] * value * lengthPerKn];
return ending
? { name, value, from: [at[0] - d[0], at[1] - d[1]], to: at }
: { name, value, from: at, to: [at[0] + d[0], at[1] + d[1]] };
};
return [
arrow("W", slice.weight, centroid, [0, -1]),
arrow("Q", slice.surcharge, midTop, [0, -1], true),
arrow("N", slice.N_eff, midBase, n, true),
arrow("U", slice.U, midBase, n, true),
arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]], true),
arrow("EL", slice.E_left, nearFace, [s, 0], true),
arrow("ER", slice.E_right, farFace, [-s, 0], true),
arrow("XL", slice.X_left, nearFace, [0, 1], true),
arrow("XR", slice.X_right, farFace, [0, -1], true),
].filter((a) => Math.abs(a.value) > 1e-9);
}
/** 절편력 그래프 꼴 — 절편 가운데 x 마다 바닥 유효 수직응력 · 간극수압 · 동원 전단응력(kPa · 한 축) */
export function sliceStress(
slices: SliceRow[],
): { x: number; sigma: number; u: number; tau: number }[] {
return slices.map((s) => {
const l = s.base_length > 0 ? s.base_length : 1;
return { x: (s.x_left + s.x_right) / 2, sigma: s.N_eff / l, u: s.pore_pressure, tau: s.S / l };
});
}
/** 그래프 눈금 — 0 포함 · 1 · 2 · 5 × 10ⁿ 간격 · 대략 count 칸 */
export function niceTicks(min: number, max: number, count = 5): number[] {
const lo = Math.min(0, min);
const hi = Math.max(lo + 1e-9, max);
const raw = (hi - lo) / Math.max(count, 1);
const mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw;
const out: number[] = [];
for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step)
out.push(Math.round(v * 1e6) / 1e6);
if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
return out;
}