216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
/* =============================================================================
|
||
* 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<string, unknown>;
|
||
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<number>((n, value) => (value === null ? n : n + 1), 0);
|
||
}
|
||
|
||
/** 래스터 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 값 없는 셀 = 투명 */
|
||
export function rasterRgba(
|
||
grid: Grid,
|
||
values: readonly (number | null)[],
|
||
steps: readonly ColorStep[],
|
||
): Uint8ClampedArray<ArrayBuffer> {
|
||
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<string, number | null>;
|
||
min?: Record<string, number | null>;
|
||
},
|
||
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;
|
||
}
|