Files
Aislo/E07_StabArea/E07_StabArea_UI_Page.ts
T
eomsangdonandClaude Sonnet 5.5 59610fecf1 feat(시간 표시): 화면 시각 = 보는 사람 PC 시간대 · 서버 응답 UTC Z · 서버가 만드는 파일 글 = 한국 시각 (PLAN 593-1 · 요구 456)
- 공용 `common_util_time.ts`(날짜 · 시각 · 오늘/어제/n 일 전 · Z 없는 옛 글 = UTC) · `common_util_time.py`(UTC Z 글 · 한국 시각)
- 화면 바꿈: 대시보드(프로젝트 · 휴지통 · 시스템 로그 · 보관함 · 홈 카드) · B09 굳힘 · E03 · E05 · E07 · M02 호표 이력 · 목록 고친 날 · 지난 판
- 서버: 응답 datetime = UTC + Z(main.py) · 로컬 시각 저장 → UTC · 대시보드 경과일 = UTC 기준 · 엑셀 날짜 · 파일 이름 = 한국 시각
- 시험: test_593_time(서울 · 뉴욕 · Z 없는 옛 글 · 서버 글)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 10:03:35 +09:00

497 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* E07_StabArea_UI_Page.ts
* E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow).
* 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률
* 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례
* 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점)
* 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = 캐시 `e07-settings`
* · 없으면 지난 결과 조건 + 물성은 지금 E02 값.
* ========================================================================== */
import "./E07_StabArea_UI_Style.css";
import { API_BASE_URL, 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 { createBanner } from "@ui/ui_template_banner";
import { createEmpty } from "@ui/ui_template_empty";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createLegend } from "@ui/ui_template_flow_legend";
import { createKv } from "@ui/ui_template_kv";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createProgressBar } from "@ui/ui_template_progress";
import { createSegment } from "@ui/ui_template_segment";
import { createTable } from "@ui/ui_template_table";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { readState, writeState } from "../A00_Common/b_page_state";
import { dateTimeText } from "../common_util/common_util_time";
import { currentProjectId } from "../A00_Common/b_project_switch";
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,
LEVEL_COLORS,
LEVEL_KEYS,
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"],
["tr_lo", "E07_TrLo", "rain"],
["tr_hi", "E07_TrHi", "rain"],
["band_m", "E07_Band", "band"],
["cell_m", "E07_Cell", "band"],
["level_lo_pct", "E07_LevelLo", "band"],
["level_hi_pct", "E07_LevelHi", "band"],
];
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;
}
const SCENARIOS = ["sinmap", "saturated"];
const scenarioName = (value: unknown): string =>
SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? "");
/** 안내 글 한 줄 — 걸이 이름(`e07-hint` 같은)만 얹음 */
function note(text: string, hook: string): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e07-empty");
return node;
}
/** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */
function legend(layer: MapLayer, scenario: unknown): HTMLElement {
const panel = createFloatPanel({
title: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS"),
});
panel.root.classList.add("e07-legend");
if (scenario) panel.body.append(note(scenarioName(scenario), "e07-legend__sub"));
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)}`,
}));
panel.body.append(createLegend(rows, { vertical: true }));
return panel.root;
}
export function renderE07StabArea(root: HTMLElement): void {
const projectId = currentProjectId();
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(emptyBox(t("E07_NoProject")));
// ---- 가운데 ----
const map = createAreaMap();
let overview: Overview | null = null;
let layer: MapLayer = "cls";
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void =>
legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario));
const layerToggle = createSegment({
options: [
{ value: "cls", label: t("E07_Layer_Class") },
{ value: "fs", label: t("E07_Layer_FS") },
],
value: "cls",
size: 24,
onChange: (value) => {
layer = value as MapLayer;
map.setLayer(layer);
paintLegend();
},
});
const toggle = layerToggle.root;
toggle.classList.add("e07-toggle");
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: SCENARIOS.map((value) => ({ value, text: scenarioName(value) })),
compact: true,
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
const sourceHint = note(t("E07_Defaults_Source"), "e07-hint");
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 = createProgressBar();
const progressText = note("", "e07-progress__text");
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar.root, 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(),
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, analyzeButton);
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
readOnly.hidden = true;
panel.append(
note(t("E07_Hint"), "e07-hint"),
readOnly,
cards.soil,
cards.rain,
cards.band,
sourceHint,
progressBox,
actions,
);
attachCollapsible(panel);
// ---- 상태 ----
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 = SCENARIOS.includes(String(values.scenario))
? String(values.scenario)
: SCENARIOS[0];
paintScenario();
remember();
}
function paintScenario(): void {
const wet = scenario.select.value === "sinmap";
for (const key of ["tr_lo", "tr_hi"]) fields.get(key)!.root.hidden = !wet;
map.setBand(Number(fields.get("band_m")!.input.value) || 100);
}
const remember = (): void => writeState("e07-settings", read(), projectId);
panel.addEventListener("input", remember);
scenario.select.addEventListener("change", () => {
paintScenario();
remember();
});
fields.get("band_m")!.input.addEventListener("input", paintScenario);
/** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */
function materialValues(id: unknown): Settings {
const m = overview?.materials.find((x) => x.id === id);
if (!m) return {};
const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c);
const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi);
return {
gamma_t: m.gamma_t,
gamma_sat: m.gamma_sat,
c: m.c,
c_hi: m.c + rangeC,
phi: m.phi,
phi_hi: m.phi + rangePhi,
};
}
material.select.addEventListener("change", () => {
const values = materialValues(material.select.value);
for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v);
remember();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(emptyBox(t("E07_Result_None")));
return;
}
const s = result.summary;
const pairs: [LocaleKey, string][] = [
["E07_Scenario", scenarioName(result.settings.scenario)],
["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_Levels",
`${LEVEL_KEYS.map((k) => `${t(`E07_Level_${k}` as LocaleKey)} ${s.station_levels[k]}`).join(" · ")} / ${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 · ${dateTimeText(result.computed_at)}`],
];
const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value })));
list.classList.add("e07-summary");
list.querySelectorAll("dd").forEach((dd, i) => dd.setAttribute("data-field", pairs[i][0]));
const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1;
/** 색 칩 + 글 한 칸 */
const chipCell = (color: string, text: string): Node => {
const cell = document.createDocumentFragment();
cell.append(swatch(color), el("span", { text }));
return cell;
};
const areas = createTable(
[
{ label: t("E07_Col_Class") },
{ label: t("E07_Col_Area"), align: "end" },
{ label: "%", align: "end" },
],
CLASS_KEYS.map((k, i) => [
chipCell(CLASS_COLORS[i], t(`E07_Class_${k}` as LocaleKey)),
Math.round(s.areas_m2[k] ?? 0).toLocaleString(),
(((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1),
]),
{ density: "data" },
);
areas.querySelector("table")!.classList.add("e07-table", "e07-areas");
areas
.querySelectorAll("tbody tr")
.forEach((tr, i) => ((tr as HTMLElement).dataset.class = CLASS_KEYS[i]));
const stations = createTable(
[
{ label: t("E07_Col_Station") },
{ label: t("E07_Col_FsMin"), align: "end" },
{ label: t("E07_Col_Ratio"), align: "end" },
{ label: t("E07_Col_Level") },
{ label: t("E07_Col_RiskArea"), align: "end" },
],
result.stations.map((r) => [
stationLabel(r.chainage_m, inferStationInterval(overview?.stations ?? []), 1),
r.fs_min === undefined ? "-" : r.fs_min.toFixed(2),
r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "",
r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
]),
{ density: "data" },
);
const table = stations.querySelector("table")!;
table.classList.add("e07-table", "e07-stations");
table.querySelectorAll("tbody tr").forEach((row, i) => {
const tr = row as HTMLElement;
const r = result.stations[i];
tr.dataset.chainage = String(r.chainage_m);
tr.classList.toggle("is-risk", r.level === "high");
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on", "is-active"));
tr.classList.add("is-on", "is-active");
map.focus(r.chainage_m);
});
});
const notes: HTMLElement[] = [];
if (result.stale) {
const warn = createBanner({ tone: "warning", text: t("E07_Result_Stale") });
warn.classList.add("e07-warn");
notes.push(warn);
}
notes.push(note(t("E07_Result_Note"), "e07-note"));
resultBody.replaceChildren(
list,
el("h4", { text: t("E07_Areas_Title") }),
areas,
el("h4", { text: t("E07_Stations_Title") }),
stations,
...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();
paintLegend();
}
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.set(p.percent / 100);
progressText.replaceChildren(`${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(): Promise<void> {
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, {
method: "POST",
body: JSON.stringify(read()),
});
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(emptyBox(error instanceof Error ? error.message : String(error)));
return;
}
const ov = overview!;
const cached = readState<Settings>("e07-settings", projectId);
// 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값
const last = ov.result?.settings as Settings | undefined;
write({
...ov.defaults,
...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }),
});
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 });
}