165 lines
5.0 KiB
TypeScript
165 lines
5.0 KiB
TypeScript
/* =============================================================================
|
||
* E07_StabArea_Raster.ts
|
||
* E07 광역 위험 지도 화면 계산(PLAN 536-2) — 저장본 격자 풀기 · 등급 · 안전율 색 · RGBA · 셀 찾기.
|
||
* 격자 꼴 = 서버 `E07_StabArea_Router.py` 머리 주석(fs · si uint16 ×1000 · cls uint8 · slope ×10 ·
|
||
* m ×100 · 작은 끝 base64 · 행 0 = 남쪽). 순수 함수만(DOM · fetch 금지).
|
||
* ========================================================================== */
|
||
|
||
export interface Grid {
|
||
x0: number;
|
||
y0: number;
|
||
dx: number;
|
||
nx: number;
|
||
ny: number;
|
||
}
|
||
|
||
export type LevelKey = "low" | "mid" | "high";
|
||
|
||
export interface StationRow {
|
||
chainage_m: number;
|
||
label: string | null;
|
||
cells: number;
|
||
fs_min?: number;
|
||
si_min?: number;
|
||
worst_class?: number;
|
||
risk_area_m2?: number;
|
||
risk_ratio?: number;
|
||
level?: LevelKey;
|
||
slope_max_deg?: number;
|
||
}
|
||
|
||
export interface AreaSummary {
|
||
areas_m2: Record<string, number>;
|
||
band_area_m2: number;
|
||
covered_area_m2: number;
|
||
risk_area_m2: number;
|
||
fs_min: number | null;
|
||
station_levels: Record<LevelKey, number>;
|
||
stations: number;
|
||
}
|
||
|
||
export interface AreaResult {
|
||
stale?: boolean;
|
||
computed_at: string;
|
||
elapsed_s: number;
|
||
settings: Record<string, unknown>;
|
||
grid: Grid;
|
||
fs: string;
|
||
si: string;
|
||
cls: string;
|
||
slope: string;
|
||
m: string;
|
||
stations: StationRow[];
|
||
summary: AreaSummary;
|
||
}
|
||
|
||
/** 풀어 둔 격자 — 자료 없음 = NaN(fs · si · slope · m) · 0(cls) */
|
||
export interface Decoded {
|
||
grid: Grid;
|
||
fs: Float32Array;
|
||
si: Float32Array;
|
||
cls: Uint8Array;
|
||
slope: Float32Array;
|
||
m: Float32Array;
|
||
}
|
||
|
||
/** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */
|
||
export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const;
|
||
export const RISK_CLASS = 4;
|
||
/** 측점 등급(서버 LEVEL_KEYS 차례) · 색 */
|
||
export const LEVEL_KEYS: readonly LevelKey[] = ["low", "mid", "high"];
|
||
export const LEVEL_COLORS: Record<LevelKey, string> = {
|
||
low: "#1a9850",
|
||
mid: "#fee08b",
|
||
high: "#d73027",
|
||
};
|
||
|
||
/** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */
|
||
export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"];
|
||
|
||
/** 안전율 색 단계 — 1.0 · 1.2 · 1.5 경계 */
|
||
export const FS_STEPS: readonly { below: number; color: string }[] = [
|
||
{ below: 1.0, color: "#a50026" },
|
||
{ below: 1.2, color: "#f46d43" },
|
||
{ below: 1.5, color: "#fee08b" },
|
||
{ below: 2.0, color: "#a6d96a" },
|
||
{ below: Infinity, color: "#1a9850" },
|
||
];
|
||
|
||
function bytes(text: string): Uint8Array {
|
||
const raw = atob(text);
|
||
const out = new Uint8Array(raw.length);
|
||
for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i);
|
||
return out;
|
||
}
|
||
|
||
function u16(text: string, scale: number): Float32Array {
|
||
const b = bytes(text);
|
||
const view = new DataView(b.buffer);
|
||
const out = new Float32Array(b.length / 2);
|
||
for (let i = 0; i < out.length; i += 1) {
|
||
const v = view.getUint16(i * 2, true);
|
||
out[i] = v === 65535 ? NaN : v / scale;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function decode(result: AreaResult): Decoded {
|
||
const m = bytes(result.m);
|
||
return {
|
||
grid: result.grid,
|
||
fs: u16(result.fs, 1000),
|
||
si: u16(result.si, 1000),
|
||
cls: bytes(result.cls),
|
||
slope: u16(result.slope, 10),
|
||
m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)),
|
||
};
|
||
}
|
||
|
||
function rgb(hex: string): [number, number, number] {
|
||
const n = Number.parseInt(hex.slice(1), 16);
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
}
|
||
|
||
export function fsColor(value: number): string {
|
||
return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color;
|
||
}
|
||
|
||
/** 격자 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 자료 없음 = 투명 */
|
||
export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray<ArrayBuffer> {
|
||
const { nx, ny } = data.grid;
|
||
const out = new Uint8ClampedArray(nx * ny * 4);
|
||
const classRgb = CLASS_COLORS.map(rgb);
|
||
const fsRgb = FS_STEPS.map((s) => rgb(s.color));
|
||
for (let iy = 0; iy < ny; iy += 1) {
|
||
const row = ny - 1 - iy;
|
||
for (let ix = 0; ix < nx; ix += 1) {
|
||
const i = iy * nx + ix;
|
||
const k = data.cls[i];
|
||
if (!k) continue;
|
||
const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below);
|
||
const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j];
|
||
const at = (row * nx + ix) * 4;
|
||
out[at] = color[0];
|
||
out[at + 1] = color[1];
|
||
out[at + 2] = color[2];
|
||
out[at + 3] = 255;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 모델 좌표 → 셀 번호(격자 밖 = -1) */
|
||
export function cellIndex(grid: Grid, x: number, y: number): number {
|
||
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 ? -1 : iy * grid.nx + ix;
|
||
}
|
||
|
||
/** 등급별 셀 수(1~6) — 범례 · 검증용 */
|
||
export function classCounts(data: Decoded): number[] {
|
||
const out = [0, 0, 0, 0, 0, 0];
|
||
for (const k of data.cls) if (k) out[k - 1] += 1;
|
||
return out;
|
||
}
|