/* ============================================================================= * 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>>; min: Partial>; verdict: Partial>; 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; 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>; /** 조건별 지하수 꼴(E02 갈래) — r_u 면 수위선 없음 */ water_rule?: Partial>; 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; }