feat(536-2): E07 광역 위험 화면 — 지도 · 측점 표 · 조건 · E 단계 막대 줄

- E07_StabArea 화면: 좌 토층 물성(E02 물성 고름 · c · φ 범위) · 강우(지표 포화 · 강우 강도) · 띠 폭 · 격자 · [기본값] · [해석] · 진행률
- 가운데 지도: SINMAP 등급 · 안전율 Fs 색 · 노선 띠 · 측점 · 범례 · 셀 툴팁 · 우 요약 · 등급별 면적 · 측점별 위험 표(누름 = 지도 그 측점)
- logic/E07_StabArea_Raster: 저장본 base64 격자 풀기 · 색 · RGBA
- 공용 틀: STAB_STEP_ROUTES · group_workflow 단계 줄에 광역 위험 · 사전 ui_template_locale_e07

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 21:33:59 +09:00
co-authored by Claude Opus 5.5
parent 0fd6437628
commit 0187610767
10 changed files with 1272 additions and 3 deletions
+8 -2
View File
@@ -31,8 +31,14 @@ const FLOWS: readonly GroupFlow[] = [
},
{
routes: STAB_STEP_ROUTES,
labels: ["DE_Step_StabGround", "DE_Step_StabSection", "DE_Step_Stab3D", "DE_Step_StabReport"],
icons: ["🪨", "📐", "🧊", "📄"],
labels: [
"DE_Step_StabGround",
"DE_Step_StabSection",
"DE_Step_Stab3D",
"DE_Step_StabReport",
"DE_Step_StabArea",
],
icons: ["🪨", "📐", "🧊", "📄", "🗺️"],
},
];
+296
View File
@@ -0,0 +1,296 @@
/* =============================================================================
* E07_StabArea_UI_Map.ts
* E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 ·
* 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
export type MapLayer = "cls" | "fs";
export interface StationPoint {
chainage_m: number;
label: string | null;
x: number;
y: number;
}
export interface AreaMap {
root: HTMLElement;
setRoute: (line: number[][], stations: StationPoint[]) => void;
setBand: (halfWidthM: number) => void;
setResult: (data: Decoded | null) => void;
setLayer: (layer: MapLayer) => void;
focus: (chainage: number) => void;
dispose: () => void;
}
interface View {
cx: number;
cy: number;
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
export function createAreaMap(): AreaMap {
const root = el("div", { className: "e07-map" });
const canvas = el("canvas", { className: "e07-map__canvas" });
const tip = el("div", { className: "e07-map__tip" });
const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") });
tip.hidden = true;
root.append(canvas, tip, empty);
const ctx = canvas.getContext("2d")!;
let line: number[][] = [];
let stations: StationPoint[] = [];
let band = 100;
let data: Decoded | null = null;
let layer: MapLayer = "cls";
let image: HTMLCanvasElement | null = null;
let picked: number | null = null;
let view: View = { cx: 0, cy: 0, scale: 1 };
let width = 0;
let height = 0;
const toScreen = (x: number, y: number): [number, number] => [
width / 2 + (x - view.cx) * view.scale,
height / 2 - (y - view.cy) * view.scale,
];
const toModel = (px: number, py: number): [number, number] => [
view.cx + (px - width / 2) / view.scale,
view.cy - (py - height / 2) / view.scale,
];
function buildImage(): void {
image = null;
if (!data) return;
const { nx, ny } = data.grid;
const off = document.createElement("canvas");
off.width = nx;
off.height = ny;
off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0);
image = off;
}
function fit(): void {
const xs = line.map((p) => p[0]);
const ys = line.map((p) => p[1]);
if (!xs.length || !width || !height) return;
const pad = band;
const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad];
const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad];
const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92;
view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale };
}
function path(): void {
ctx.beginPath();
line.forEach((p, i) =>
i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])),
);
}
function drawRoute(text: string, accent: string): void {
if (line.length < 2) return;
ctx.lineJoin = ctx.lineCap = "round";
// 노선 띠 — 중심선 ± band(옅은 띠)
ctx.globalAlpha = 0.12;
ctx.strokeStyle = accent;
ctx.lineWidth = Math.max(2 * band * view.scale, 1);
path();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.strokeStyle = text;
ctx.lineWidth = 2;
path();
ctx.stroke();
const gap = stations.length > 1 ? 20 * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
stations.forEach((s, i) => {
const [px, py] = toScreen(s.x, s.y);
if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return;
const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6;
ctx.fillStyle = on ? accent : text;
ctx.beginPath();
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
});
}
function drawFurniture(text: string): void {
ctx.fillStyle = text;
ctx.strokeStyle = text;
ctx.beginPath();
ctx.moveTo(width - 24, 14);
ctx.lineTo(width - 30, 34);
ctx.lineTo(width - 18, 34);
ctx.closePath();
ctx.fill();
ctx.font = "bold 12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("N", width - 24, 46);
const target = 100 / view.scale;
const pow = 10 ** Math.floor(Math.log10(target));
const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target;
const px = meters * view.scale;
ctx.fillStyle = css("--color-surface", "#ffffff");
ctx.globalAlpha = 0.85;
ctx.fillRect(width - 24 - px, height - 42, px + 16, 34);
ctx.globalAlpha = 1;
ctx.fillStyle = text;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(width - 16 - px, height - 16);
ctx.lineTo(width - 16, height - 16);
ctx.stroke();
ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26);
ctx.textAlign = "start";
}
function draw(): void {
const dpr = window.devicePixelRatio || 1;
width = root.clientWidth;
height = root.clientHeight;
if (!width || !height) return;
if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
const text = css("--color-text", "#1f2937");
const accent = css("--color-primary", "#6d28d9");
if (data && image) {
const g = data.grid;
const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale);
}
drawRoute(text, accent);
drawFurniture(text);
empty.hidden = Boolean(data);
}
let drag: { x: number; y: number; view: View } | null = null;
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const k = Math.exp(-event.deltaY * 0.0015);
view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k };
draw();
});
canvas.addEventListener("pointerdown", (event) => {
drag = { x: event.clientX, y: event.clientY, view: { ...view } };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointerup", () => (drag = null));
canvas.addEventListener("dblclick", () => {
fit();
draw();
});
canvas.addEventListener("pointerleave", () => (tip.hidden = true));
canvas.addEventListener("pointermove", (event) => {
if (drag) {
view = {
...drag.view,
cx: drag.view.cx - (event.clientX - drag.x) / view.scale,
cy: drag.view.cy + (event.clientY - drag.y) / view.scale,
};
draw();
return;
}
const rect = canvas.getBoundingClientRect();
const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top);
const i = data ? cellIndex(data.grid, mx, my) : -1;
if (i < 0 || !data!.cls[i]) return void (tip.hidden = true);
const near = stations.reduce<StationPoint | null>(
(best, s) =>
!best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2
? s
: best,
null,
);
const m = data!.m[i];
tip.textContent = [
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
]
.filter(Boolean)
.join("\n");
tip.style.left = `${event.clientX - rect.left + 14}px`;
tip.style.top = `${event.clientY - rect.top + 14}px`;
tip.hidden = false;
});
const resize = new ResizeObserver(() => {
const first = !width;
draw();
if (first) {
fit();
draw();
}
});
resize.observe(root);
const theme = new MutationObserver(draw);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
// 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층
const probe = {
project: (x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const [px, py] = toScreen(x, y);
return { x: rect.left + px, y: rect.top + py };
},
data: () => data,
layer: () => layer,
};
(window as unknown as { __e07Map?: typeof probe }).__e07Map = probe;
return {
root,
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
fit();
draw();
},
setBand(halfWidthM) {
band = halfWidthM;
draw();
},
setResult(next) {
data = next;
buildImage();
draw();
},
setLayer(next) {
layer = next;
buildImage();
draw();
},
focus(chainage) {
picked = chainage;
const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6);
if (s) view = { ...view, cx: s.x, cy: s.y };
draw();
},
dispose() {
resize.disconnect();
theme.disconnect();
if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe)
delete (window as unknown as { __e07Map?: unknown }).__e07Map;
},
};
}
+478
View File
@@ -0,0 +1,478 @@
/* =============================================================================
* E07_StabArea_UI_Page.ts
* E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow).
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 강우(지표 포화 · 강우 강도) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
CLASS_COLORS,
CLASS_KEYS,
FS_STEPS,
RISK_CLASS,
decode,
type AreaResult,
} from "./logic/E07_StabArea_Raster";
interface Material {
id: string;
name: string;
gamma_t: number;
gamma_sat: number;
c: number;
phi: number;
}
type Settings = Record<string, number | string | null>;
interface Overview {
editable: boolean;
defaults: Settings;
materials: Material[];
line: number[][];
stations: StationPoint[];
result: AreaResult | null;
}
interface Progress {
running: boolean;
percent: number;
message: string | null;
error: string | null;
}
const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
["gamma_t", "E07_GammaT", "soil"],
["gamma_sat", "E07_GammaSat", "soil"],
["c", "E07_C", "soil"],
["c_hi", "E07_CHi", "soil"],
["phi", "E07_Phi", "soil"],
["phi_hi", "E07_PhiHi", "soil"],
["c_root", "E07_CRoot", "soil"],
["soil_depth_m", "E07_Depth", "soil"],
["rain_mm_h", "E07_Rain", "rain"],
["k_m_s", "E07_K", "rain"],
["band_m", "E07_Band", "band"],
["cell_m", "E07_Cell", "band"],
];
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
const shortLabel = (label: string | null, ch: number): string =>
(label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, "");
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
const text = Array.isArray(detail)
? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ")
: detail;
throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`);
}
return body;
}
function swatch(color: string): HTMLElement {
const chip = el("i", { className: "e07-chip" });
chip.style.background = color;
return chip;
}
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e07-legend" });
box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") }));
const rows =
layer === "cls"
? CLASS_KEYS.map((k, i) => ({
color: CLASS_COLORS[i],
label: t(`E07_Class_${k}` as LocaleKey),
}))
: FS_STEPS.map((s, i) => ({
color: s.color,
label:
i === 0
? `< ${s.below.toFixed(1)}`
: s.below === Infinity
? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}`
: `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`,
}));
for (const row of rows) {
const item = el("span", { className: "e07-legend__row" });
item.append(swatch(row.color), el("span", { text: row.label }));
box.append(item);
}
return box;
}
export function renderE07StabArea(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e07-panel" });
const main = el("div", { className: "e07-page" });
mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") }));
// ---- 가운데 ----
const map = createAreaMap();
let layer: MapLayer = "cls";
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
const toggle = el("div", { className: "e07-toggle" });
const layerButtons = (
[
["E07_Layer_Class", "cls"],
["E07_Layer_FS", "fs"],
] as [LocaleKey, MapLayer][]
).map(([key, value], i) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = value;
button.classList.toggle("is-on", i === 0);
button.addEventListener("click", () => {
layerButtons.forEach((b) => b.classList.toggle("is-on", b === button));
layer = value;
map.setLayer(layer);
paintLegend();
});
return button;
});
toggle.append(...layerButtons);
const stage = el("div", { className: "e07-stage" });
stage.append(map.root, legendSlot);
const center = el("div", { className: "e07-center" });
center.append(toggle, stage);
paintLegend();
// ---- 우 ----
const resultBody = el("div", { className: "e07-result" });
const side = createSidePanels({
prefKey: "e07-stab-area-result",
panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }],
defaultOpen: "result",
defaultWidth: 380,
follow: false,
});
const row = el("div", { className: "ui-side-panels-row e07-row" });
row.append(center, side.root);
main.append(row);
// ---- 좌 ----
const fields = new Map<string, InputFieldHandle>();
const cards = {
soil: createPanelCard(t("E07_Card_Soil"), false),
rain: createPanelCard(t("E07_Card_Rain"), false),
band: createPanelCard(t("E07_Card_Band"), false),
};
const material = createSelectField({ label: t("E07_Material"), options: [], compact: true });
const scenario = createSelectField({
label: t("E07_Scenario"),
options: [
{ value: "saturated", text: t("E07_Scenario_saturated") },
{ value: "rain", text: t("E07_Scenario_rain") },
],
compact: true,
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
for (const [key, label, card] of NUMBER_FIELDS) {
const field = createInputField({ label: t(label), type: "number", compact: true });
field.input.step = "any";
field.input.dataset.key = key;
fields.set(key, field);
cards[card].append(field.root);
}
const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e07-progress__text" });
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar, progressText);
progressBox.hidden = true;
const defaultsButton = createButton({
label: t("E07_Defaults"),
variant: "ghost",
onClick: () => overview && write(overview.defaults),
});
const analyzeButton = createButton({
label: t("E07_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
});
const reanalyzeButton = createButton({
label: t("E07_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") });
readOnly.hidden = true;
panel.append(
el("p", { className: "e07-hint", text: t("E07_Hint") }),
readOnly,
cards.soil,
cards.rain,
cards.band,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
let overview: Overview | null = null;
let running = false;
function read(): Settings {
const out: Settings = { material_id: material.select.value || null };
out.scenario = scenario.select.value;
for (const [key, field] of fields) out[key] = Number(field.input.value);
return out;
}
function write(values: Settings): void {
for (const [key, field] of fields) field.input.value = String(values[key] ?? "");
material.select.value = String(values.material_id ?? "");
scenario.select.value = String(values.scenario ?? "saturated");
paintScenario();
remember();
}
function paintScenario(): void {
const rain = scenario.select.value === "rain";
for (const key of ["rain_mm_h", "k_m_s"]) fields.get(key)!.root.hidden = !rain;
map.setBand(Number(fields.get("band_m")!.input.value) || 100);
}
const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read()));
panel.addEventListener("input", remember);
scenario.select.addEventListener("change", () => {
paintScenario();
remember();
});
fields.get("band_m")!.input.addEventListener("input", paintScenario);
material.select.addEventListener("change", () => {
const m = overview?.materials.find((x) => x.id === material.select.value);
if (!m) return;
const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v));
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
set("gamma_t", m.gamma_t);
set("gamma_sat", m.gamma_sat);
set("c", m.c);
set("c_hi", m.c + rangeC);
set("phi", m.phi);
set("phi_hi", m.phi + rangePhi);
remember();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") }));
return;
}
const s = result.summary;
const list = el("dl", { className: "e07-summary" });
const pairs: [LocaleKey, string][] = [
["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)],
["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`],
["E07_Sum_RiskStations", `${s.risk_stations.length} / ${s.stations}`],
[
"E07_Sum_Covered",
`${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`,
],
[
"E07_Sum_Time",
`${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`,
],
];
for (const [key, value] of pairs)
list.append(
el("dt", { text: t(key) }),
el("dd", { text: value, attrs: { "data-field": key } }),
);
const areas = el("table", { className: "e07-table e07-areas" });
const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1;
areas.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Class") }),
el("th", { text: t("E07_Col_Area") }),
el("th", { text: "%" }),
],
}),
...CLASS_KEYS.map((k, i) => {
const name = el("td");
name.append(
swatch(CLASS_COLORS[i]),
el("span", { text: t(`E07_Class_${k}` as LocaleKey) }),
);
return el("tr", {
attrs: { "data-class": k },
children: [
name,
el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }),
el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }),
],
});
}),
);
const table = el("table", { className: "e07-table e07-stations" });
table.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Station") }),
el("th", { text: t("E07_Col_FsMin") }),
el("th", { text: t("E07_Col_SiMin") }),
el("th", { text: t("E07_Col_Worst") }),
el("th", { text: t("E07_Col_RiskArea") }),
],
}),
...result.stations.map((r) => {
const worst = el("td");
if (r.worst_class)
worst.append(
swatch(CLASS_COLORS[r.worst_class - 1]),
el("span", { text: t(`E07_Class_${CLASS_KEYS[r.worst_class - 1]}` as LocaleKey) }),
);
const tr = el("tr", {
className: (r.worst_class ?? 0) >= RISK_CLASS ? "is-risk" : "",
attrs: { "data-chainage": String(r.chainage_m) },
children: [
el("td", { text: shortLabel(r.label, r.chainage_m) }),
el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }),
el("td", { text: r.si_min === undefined ? "-" : r.si_min.toFixed(2) }),
worst,
el("td", {
text:
r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
}),
],
});
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on"));
tr.classList.add("is-on");
map.focus(r.chainage_m);
});
return tr;
}),
);
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") }));
notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") }));
resultBody.replaceChildren(
list,
el("h4", { text: t("E07_Areas_Title") }),
areas,
el("h4", { text: t("E07_Stations_Title") }),
table,
...notes,
);
}
async function refresh(): Promise<void> {
overview = await api<Overview>(`/projects/${projectId}/stab/area`);
material.setOptions(
overview.materials.map((m) => ({ value: m.id, text: m.name })),
String(overview.defaults.material_id ?? ""),
);
map.setResult(overview.result ? decode(overview.result) : null);
paintButtons();
paintResult();
}
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`;
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
else showToast(t("E07_Done"), "success");
await refresh();
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
async function analyze(force: boolean): Promise<void> {
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
method: "POST",
body: JSON.stringify({ ...read(), force }),
});
if (body.status === "cached") {
showToast(t("E07_Cached"), "info");
await refresh();
return;
}
running = true;
paintButtons();
progressBox.hidden = false;
void poll();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
// ---- 처음 ----
paintButtons();
void (async () => {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e07-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return;
}
const ov = overview!;
const cached = JSON.parse(
sessionStorage.getItem(cacheKey(projectId)) ?? "null",
) as Settings | null;
write(cached ?? (ov.result?.settings as Settings | undefined) ?? ov.defaults);
map.setRoute(ov.line, ov.stations);
const p = await api<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
if (p?.running) void poll();
})();
const leave = new MutationObserver(() => {
if (root.isConnected && main.isConnected) return;
leave.disconnect();
map.dispose();
});
leave.observe(document.body, { childList: true, subtree: true });
}
+231
View File
@@ -0,0 +1,231 @@
/* =============================================================================
* E07_StabArea_UI_Style.css
* E07 광역 위험 지도 — 좌측 조건 · 진행 / 가운데 지도 · 범례 / 우측 요약 · 측점 표
* 색은 테마 토큰만(밝음 · 어두움 같이) · 등급 · 안전율 색만 logic 고정색
* ========================================================================== */
.e07-page {
height: 100dvh;
box-sizing: border-box;
}
.e07-row {
height: 100%;
}
.e07-center {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
min-height: 0;
padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12);
box-sizing: border-box;
}
.e07-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e07-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e07-stage {
position: relative;
flex: 1 1 auto;
min-height: 320px;
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
overflow: hidden;
}
.e07-map {
position: absolute;
inset: 0;
}
.e07-map__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.e07-map__tip {
position: absolute;
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
white-space: pre;
pointer-events: none;
}
.e07-map__empty {
position: absolute;
inset: auto 0 45% 0;
margin: 0;
text-align: center;
color: var(--color-text-muted);
pointer-events: none;
}
.e07-legend-slot {
position: absolute;
left: var(--spacing-8);
bottom: var(--spacing-8);
pointer-events: none;
}
.e07-legend {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
}
.e07-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e07-chip {
display: inline-block;
width: 14px;
height: 10px;
flex: none;
margin-right: var(--spacing-4);
border-radius: 2px;
border: 1px solid var(--color-border);
vertical-align: middle;
}
/* 좌측 */
.e07-hint,
.e07-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
}
.e07-panel .ui-field[hidden] {
display: none;
}
.e07-progress-box {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.e07-progress-box[hidden] {
display: none;
}
.e07-progress {
width: 100%;
}
.e07-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e07-result {
padding: var(--spacing-12);
color: var(--color-text);
}
.e07-result h4 {
margin: var(--spacing-16) 0 var(--spacing-8);
font-size: 13px;
}
.e07-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e07-summary dt {
color: var(--color-text-muted);
}
.e07-summary dd {
margin: 0;
font-weight: 600;
text-align: right;
}
.e07-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.e07-table th,
.e07-table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: right;
white-space: nowrap;
}
.e07-table th:first-child,
.e07-table td:first-child,
.e07-stations td:nth-child(4) {
text-align: left;
}
.e07-table th {
color: var(--color-text-muted);
font-weight: 600;
}
.e07-stations tr[data-chainage] {
cursor: pointer;
}
.e07-stations tr[data-chainage]:hover {
background: var(--color-surface-raised);
}
.e07-stations tr.is-risk td {
color: var(--color-danger);
font-weight: 600;
}
.e07-stations tr.is-on {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.e07-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
}
.e07-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+16
View File
@@ -0,0 +1,16 @@
/* =============================================================================
* E07_StabArea_Route.ts
* 길 선언(PLAN 536-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E07 광역 위험 지도 페이지
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import type { DraftRoute } from "../../A00_Common/router";
export const ROUTE: DraftRoute = {
path: ROUTES.E07_STAB_AREA,
workflow: true,
project: true,
footerless: true,
load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea,
};
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* 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 interface StationRow {
chainage_m: number;
label: string | null;
cells: number;
fs_min?: number;
si_min?: number;
worst_class?: number;
risk_area_m2?: number;
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;
risk_stations: 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;
/** 등급 색 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;
}
+3 -1
View File
@@ -130,6 +130,7 @@ export const ROUTES = {
E03_STAB_SECTION: "e03-stab-section",
E04_STAB_3D: "e04-stab-3d",
E05_STAB_REPORT: "e05-stab-report",
E07_STAB_AREA: "e07-stab-area",
} as const;
/** D 예정노선 단계 주소(D02 → D04 · 등록 D01 은 단계 밖) */
@@ -139,12 +140,13 @@ export const PLAN_STEP_ROUTES = [
ROUTES.D04_PLAN_CONFIRM,
] as const;
/** E 안정성평가 단계 주소(E02 → E05 · 생성 E01 은 단계 밖) */
/** E 안정성평가 단계 주소(E02 → E05 · 광역 E07 · 생성 E01 은 단계 밖) */
export const STAB_STEP_ROUTES = [
ROUTES.E02_STAB_GROUND,
ROUTES.E03_STAB_SECTION,
ROUTES.E04_STAB_3D,
ROUTES.E05_STAB_REPORT,
ROUTES.E07_STAB_AREA,
] as const;
export type RouteKey = keyof typeof ROUTES;
+3
View File
@@ -16,6 +16,7 @@
* - ui_template_locale_b5.ts : B 그룹 끝 (B08~B11)
* - ui_template_locale_de.ts : D 예정노선 · E 안정성평가 · 대시보드 [+] 고르기
* - ui_template_locale_e04.ts : E04 안정성평가 3D 해석
* - ui_template_locale_e07.ts : E07 안정성평가 광역 위험 지도
*
* 이 파일의 export 시그니처는 분할 이전과 동일하므로 소비처 import 수정은 불필요하다.
* ========================================================================== */
@@ -35,6 +36,7 @@ import { ui_locales_m2 } from "./ui_template_locale_m2";
import { ui_locales_de } from "./ui_template_locale_de";
import { ui_locales_e } from "./ui_template_locale_e";
import { ui_locales_e05 } from "./ui_template_locale_e05";
import { ui_locales_e07 } from "./ui_template_locale_e07";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -77,6 +79,7 @@ export const ui_locales = {
...ui_locales_de,
...ui_locales_e,
...ui_locales_e05,
...ui_locales_e07,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+1
View File
@@ -17,6 +17,7 @@ export const ui_locales_de = {
DE_Step_StabSection: ["2D 해석", "2D Analysis"],
DE_Step_Stab3D: ["3D 해석", "3D Analysis"],
DE_Step_StabReport: ["보고서", "Report"],
DE_Step_StabArea: ["광역 위험", "Area Hazard"],
DE_Step_Empty: ["준비 중인 단계입니다.", "This step is not ready yet."],
/* --- D04 확정 · 업체 · 내보내기 · B03 예정노선 가져오기(531-3) --- */
+83
View File
@@ -0,0 +1,83 @@
/* =============================================================================
* ui_template_locale_e07.ts
* 다국어 사전 — E07 안정성평가 광역 자연사면 위험 지도(요구 345 · 351 · PLAN 536-2)
*
* 신규 문구는 해당 섹션 최하단에 추가.
* ========================================================================== */
import type { LocaleEntry } from "./ui_template_locale_common";
export const ui_locales_e07 = {
/* --- E07 광역 위험 지도 --- */
E07_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
E07_Hint: [
"노선 띠 안 자연사면의 표층 붕괴 위험 — 무한사면 안전율과 SINMAP 안정 지수(SI)로 등급을 매깁니다. 지형 = 복사한 원지형.",
"Shallow-landslide hazard of natural slopes in the route band — infinite-slope Fs and SINMAP stability index (SI). Terrain = copied original DTM.",
],
E07_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E07_Card_Soil: ["토층 물성", "Soil layer"],
E07_Card_Rain: ["강우 · 포화", "Rain · saturation"],
E07_Card_Band: ["노선 띠 · 격자", "Route band · grid"],
E07_Material: ["물성(E02 지반 조건)", "Material (E02 ground)"],
E07_GammaT: ["γt (kN/m³)", "γt (kN/m³)"],
E07_GammaSat: ["γsat (kN/m³)", "γsat (kN/m³)"],
E07_C: ["c 아래 (kPa)", "c low (kPa)"],
E07_CHi: ["c 위 (kPa)", "c high (kPa)"],
E07_Phi: ["φ 아래 (°)", "φ low (°)"],
E07_PhiHi: ["φ 위 (°)", "φ high (°)"],
E07_CRoot: ["뿌리 점착 (kPa)", "Root cohesion (kPa)"],
E07_Depth: ["토층 두께 (m)", "Soil depth (m)"],
E07_Scenario: ["시나리오", "Scenario"],
E07_Scenario_saturated: ["지표 포화 (m = 1)", "Fully saturated (m = 1)"],
E07_Scenario_rain: ["강우 강도 (정상류)", "Rainfall (steady state)"],
E07_Rain: ["강우 강도 (mm/h)", "Rainfall (mm/h)"],
E07_K: ["투수계수 (m/s)", "Hydraulic conductivity (m/s)"],
E07_Band: ["띠 폭 ± (m)", "Band half-width (m)"],
E07_Cell: ["격자 (m)", "Cell (m)"],
E07_Defaults: ["기본값", "Defaults"],
E07_Analyze: ["해석", "Analyze"],
E07_Reanalyze: ["다시 해석", "Re-analyze"],
E07_Running: ["해석 중", "Analyzing"],
E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."],
E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."],
E07_Layer_Class: ["등급 (SI)", "Class (SI)"],
E07_Layer_FS: ["안전율 Fs", "Safety factor Fs"],
E07_Legend_Class: ["SINMAP 등급", "SINMAP class"],
E07_Legend_FS: ["무한사면 Fs", "Infinite-slope Fs"],
E07_Class_stable: ["안정 (SI ≥ 1.5)", "Stable (SI ≥ 1.5)"],
E07_Class_moderate: ["보통 안정 (≥ 1.25)", "Moderately stable (≥ 1.25)"],
E07_Class_quasi: ["준안정 (≥ 1.0)", "Quasi-stable (≥ 1.0)"],
E07_Class_lower: ["위험 하한 (≥ 0.5)", "Lower threshold (≥ 0.5)"],
E07_Class_upper: ["위험 상한 (> 0)", "Upper threshold (> 0)"],
E07_Class_defended: ["불안정 (SI = 0)", "Defended (SI = 0)"],
E07_Map_Empty: [
"[해석] 을 누르면 위험 지도가 나옵니다.",
"Press [Analyze] to draw the hazard map.",
],
E07_Tip_Slope: ["경사", "Slope"],
E07_Tip_Station: ["가까운 측점", "Nearest station"],
E07_Result_Title: ["광역 결과", "Area result"],
E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."],
E07_Result_Stale: [
"엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].",
"The engine changed — re-analyze.",
],
E07_Result_Note: [
"문헌 추정치 · 현장 시험 없음. SI = c · φ 범위 안에서 Fs > 1 일 확률(SI ≥ 1 이면 가장 나쁜 값의 Fs). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",
"Literature values, no field tests. SI = probability of Fs > 1 over the c · φ ranges (worst-case Fs when SI ≥ 1). Upslope area beyond the terrain grid is not counted.",
],
E07_Sum_FsMin: ["띠 안 최소 Fs", "Min Fs in band"],
E07_Sum_Risk: ["위험 면적 (SI < 1)", "Hazard area (SI < 1)"],
E07_Sum_RiskStations: ["위험 측점", "Hazard stations"],
E07_Sum_Covered: ["지형 있는 띠 / 띠", "Band with terrain / band"],
E07_Sum_Time: ["해석", "Computed"],
E07_Areas_Title: ["등급별 면적", "Area by class"],
E07_Stations_Title: ["측점별 위험 (누르면 지도로)", "Hazard by station (click to locate)"],
E07_Col_Class: ["등급", "Class"],
E07_Col_Area: ["면적 (m²)", "Area (m²)"],
E07_Col_Station: ["측점", "Station"],
E07_Col_FsMin: ["최소 Fs", "Min Fs"],
E07_Col_SiMin: ["최소 SI", "Min SI"],
E07_Col_Worst: ["나쁜 등급", "Worst class"],
E07_Col_RiskArea: ["위험 m²", "Hazard m²"],
} as const satisfies Record<string, LocaleEntry>;