/* ============================================================================= * E04_Stab3D_Raster.ts * E04 3D 해석 화면 계산(PLAN 533-3) — 래스터 꼴 · 색 단계 · 범례 · 셀 찾기 · 3D/2D 비. * 래스터 꼴 = 서버 `E04_Stab3D_Run.py` 머리 주석(grid · z · f · v · m · best). * 순수 함수만(DOM · three · fetch 금지). * ========================================================================== */ export interface Grid { x0: number; y0: number; dx: number; nx: number; ny: number; } /** 전체 최소 활동면 — 구(ratio 1) · 타원체(회전축 방향 반축 = ratio·r · 활동 방향에 수직) */ export interface SlipBest { x: number; y: number; z: number; r: number; ratio: number; azimuth_deg: number; // 활동 방향(북 0° · 시계) f: number; volume: number; method: number; ix: number; iy: number; /** 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] · 활동면을 그 높이에서 자름 */ floor?: [number, number][]; /** 활동체 덩이 셀 번호(엔진 · 한 구가 자른 여러 덩이 중 이 덩이) — 없으면(옛 저장본) 잘린 셀 전부 */ cells?: number[]; } export interface Summary3d { start_m: number; end_m: number; f_min: number; volume: number; method: string; chainage_m: number; cells: number; trials: number; condition: string; reference_fs: number; verdict: "ok" | "ng"; /** 같은 임계면의 방법별 안전율 */ by_method: { hovland: number; bishop: number; janbu_corrected: number }; } export interface Result3d { key: string; stale: boolean; computed_at: string; elapsed_s: number; settings: Record; grid: Grid; z: (number | null)[]; f: (number | null)[]; v: (number | null)[]; m: (number | null)[]; best: SlipBest; summary: Summary3d; } export interface ColorStep { /** 이 값 미만이면 이 색(마지막은 Infinity) */ below: number; color: string; } /** 안전율 색 단계 — 1.3(우기) · 1.5(건기) = KDS 11 70 05 기준 경계 · 붉을수록 위험 */ export const FS_STEPS: readonly ColorStep[] = [ { below: 1.0, color: "#a50026" }, { below: 1.2, color: "#d73027" }, { below: 1.3, color: "#f46d43" }, { below: 1.5, color: "#fdae61" }, { below: 2.0, color: "#fee08b" }, { below: 3.0, color: "#a6d96a" }, { below: Infinity, color: "#1a9850" }, ]; /** 임계 체적 색 단계(m³ · 10 배씩) — 짙을수록 큼 */ export const VOLUME_STEPS: readonly ColorStep[] = [ { below: 10, color: "#fde725" }, { below: 100, color: "#5ec962" }, { below: 1000, color: "#21918c" }, { below: 10000, color: "#3b528b" }, { below: Infinity, color: "#440154" }, ]; export const NO_DATA_COLOR = "#9aa3ad"; /** 값 → 단계 번호(null · NaN = -1) */ export function stepIndex(steps: readonly ColorStep[], value: number | null): number { if (value === null || !Number.isFinite(value)) return -1; return steps.findIndex((step) => value < step.below); } export function stepColor(steps: readonly ColorStep[], value: number | null): string { const index = stepIndex(steps, value); return index < 0 ? NO_DATA_COLOR : steps[index].color; } /** "#rrggbb" → [r, g, b] 0~1 */ export function hexToRgb(hex: string): [number, number, number] { const n = Number.parseInt(hex.slice(1), 16); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; } const fmt = (value: number, digits: number): string => value >= 1000 ? value.toLocaleString("en-US") : value.toFixed(digits); /** 범례 줄 — "< 1.0" · "1.0 – 1.2" · … · "≥ 3.0" */ export function legendRows( steps: readonly ColorStep[], digits: number, ): { color: string; label: string }[] { return steps.map((step, index) => { const low = index === 0 ? null : steps[index - 1].below; const label = low === null ? `< ${fmt(step.below, digits)}` : step.below === Infinity ? `≥ ${fmt(low, digits)}` : `${fmt(low, digits)} – ${fmt(step.below, digits)}`; return { color: step.color, label }; }); } /** 셀 (ix, iy) 중심 모델 좌표 */ export function cellCenter(grid: Grid, ix: number, iy: number): { x: number; y: number } { return { x: grid.x0 + ix * grid.dx, y: grid.y0 + iy * grid.dx }; } /** 모델 좌표 → 셀(격자 밖 = null) */ export function cellAt(grid: Grid, x: number, y: number): { ix: number; iy: number } | null { const ix = Math.round((x - grid.x0) / grid.dx); const iy = Math.round((y - grid.y0) / grid.dx); return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? null : { ix, iy }; } /** 래스터 최소 셀(값 없으면 null) */ export function rasterMin( grid: Grid, values: readonly (number | null)[], ): { ix: number; iy: number; value: number } | null { let best = -1; values.forEach((value, index) => { if (value !== null && (best < 0 || value < (values[best] as number))) best = index; }); return best < 0 ? null : { ix: best % grid.nx, iy: Math.floor(best / grid.nx), value: values[best] as number }; } /** 값이 있는 셀 수 */ export function countCells(values: readonly (number | null)[]): number { return values.reduce((n, value) => (value === null ? n : n + 1), 0); } /** 래스터 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 값 없는 셀 = 투명 */ export function rasterRgba( grid: Grid, values: readonly (number | null)[], steps: readonly ColorStep[], ): Uint8ClampedArray { const out = new Uint8ClampedArray(grid.nx * grid.ny * 4); const rgb = steps.map((step) => hexToRgb(step.color).map((c) => Math.round(c * 255))); for (let iy = 0; iy < grid.ny; iy += 1) { const row = grid.ny - 1 - iy; for (let ix = 0; ix < grid.nx; ix += 1) { const index = stepIndex(steps, values[iy * grid.nx + ix]); if (index < 0) continue; const at = (row * grid.nx + ix) * 4; out[at] = rgb[index][0]; out[at + 1] = rgb[index][1]; out[at + 2] = rgb[index][2]; out[at + 3] = 255; } } return out; } /** 2D 측점 한 줄의 3D 조건 안전율 — 지배 비탈 fs_by_condition · 없으면 조건별 최소 min · 없으면 null */ export function fs2dOf( row: { fs_by_condition?: Record; min?: Record; }, condition: string, ): number | null { const v = row.fs_by_condition?.[condition] ?? row.min?.[condition]; return typeof v === "number" && Number.isFinite(v) ? v : null; } /** 구간 안 2D 측점 최소 안전율(없으면 null) */ export function min2d( stations: readonly { chainage_m: number; fs_min: number | null }[], start: number, end: number, ): number | null { let best: number | null = null; for (const s of stations) { if (s.chainage_m < start - 1e-6 || s.chainage_m > end + 1e-6) continue; if (s.fs_min === null || !Number.isFinite(s.fs_min)) continue; if (best === null || s.fs_min < best) best = s.fs_min; } return best; } /** 3D/2D 비 — 둘 중 하나라도 없으면 null */ export function ratio3d2d(f3d: number | null, f2d: number | null): number | null { return f3d === null || f2d === null || !(f2d > 0) ? null : f3d / f2d; }