Files
Aislo/E03_StabSection/logic/E03_StabSection_Logic.ts
T
eomsangdonandClaude Opus 5.5 0eb9b9071a feat(532-5): E03 → E05 보고서 꼴 내보냄 · 지하수 갈래 지문 · r_u 범례 · 이음매 시험
- [해석] 끝 · [대표 단면 저장] 때 E03_StabSection/_derived/result2d.json(측점 줄 · 지배 비탈 · 조건별 최소 · 판정) · results/<대표 측점>.json · representative.json stations 덧붙임(overrides 그대로)
- 측점 지문에 E02 지하수 갈래 · 일꾼 job 에 지반 조건(sub4 analyze_station 고리 뒤 측점 수위로 이음)
- 측점 보기 water_rule — r_u 갈래면 범례에 r_u 값(수위선 없음)
- 시험 test_532_5_e03_seam — 벽 측점 임계면 · 벽 측점만 지문 · 지하수 갈래 지문 · 수위선 갈래 · 빠진 횡단 · E05 가 E03 저장본으로 빌더 돎

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:46:57 +09:00

413 lines
15 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;
}
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>;
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 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 };
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
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;
}