- [해석] 끝 · [대표 단면 저장] 때 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>
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|