refactor(E07): 보기 고르기 · 범례 판 · 요약 값 목록 · 등급 표 · 측점 표 · 진행 막대 · 띠 알림을 부품으로 (PLAN 553-4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:37:53 +09:00
co-authored by Claude Sonnet 5.5
parent bb6c67deab
commit e761dda222
3 changed files with 150 additions and 212 deletions
+114 -117
View File
@@ -18,8 +18,17 @@ import {
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 { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
@@ -107,11 +116,26 @@ 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 box = el("div", { className: "e07-legend" });
box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") }));
if (scenario)
box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) }));
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) => ({
@@ -127,12 +151,8 @@ function legend(layer: MapLayer, scenario: unknown): HTMLElement {
? `≥ ${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;
panel.body.append(createLegend(rows, { vertical: true }));
return panel.root;
}
export function renderE07StabArea(root: HTMLElement): void {
@@ -140,8 +160,7 @@ export function renderE07StabArea(root: HTMLElement): void {
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") }));
if (!projectId) return void main.append(emptyBox(t("E07_NoProject")));
// ---- 가운데 ----
const map = createAreaMap();
@@ -150,25 +169,21 @@ export function renderE07StabArea(root: HTMLElement): void {
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void =>
legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario));
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;
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();
});
return button;
},
});
toggle.append(...layerButtons);
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" });
@@ -203,7 +218,7 @@ export function renderE07StabArea(root: HTMLElement): void {
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") });
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";
@@ -211,10 +226,10 @@ export function renderE07StabArea(root: HTMLElement): void {
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 progressBar = createProgressBar();
const progressText = note("", "e07-progress__text");
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar, progressText);
progressBox.append(progressBar.root, progressText);
progressBox.hidden = true;
const defaultsButton = createButton({
label: t("E07_Defaults"),
@@ -233,10 +248,10 @@ export function renderE07StabArea(root: HTMLElement): void {
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") });
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
readOnly.hidden = true;
panel.append(
el("p", { className: "e07-hint", text: t("E07_Hint") }),
note(t("E07_Hint"), "e07-hint"),
readOnly,
cards.soil,
cards.rain,
@@ -304,11 +319,10 @@ export function renderE07StabArea(root: HTMLElement): void {
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") }));
resultBody.replaceChildren(emptyBox(t("E07_Result_None")));
return;
}
const s = result.summary;
const list = el("dl", { className: "e07-summary" });
const pairs: [LocaleKey, string][] = [
["E07_Scenario", scenarioName(result.settings.scenario)],
["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)],
@@ -326,88 +340,76 @@ export function renderE07StabArea(root: HTMLElement): void {
`${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 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;
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 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" },
);
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_Ratio") }),
el("th", { text: t("E07_Col_Level") }),
el("th", { text: t("E07_Col_RiskArea") }),
],
}),
...result.stations.map((r) => {
const level = el("td");
if (r.level)
level.append(
swatch(LEVEL_COLORS[r.level]),
el("span", { text: t(`E07_Level_${r.level}` as LocaleKey) }),
);
const tr = el("tr", {
className: r.level === "high" ? "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.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
}),
level,
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;
}),
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) => [
shortLabel(r.label, r.chainage_m),
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) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") }));
notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") }));
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") }),
table,
stations,
...notes,
);
}
@@ -430,8 +432,8 @@ export function renderE07StabArea(root: HTMLElement): void {
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`;
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");
@@ -469,12 +471,7 @@ export function renderE07StabArea(root: HTMLElement): void {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e07-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
main.replaceChildren(emptyBox(error instanceof Error ? error.message : String(error)));
return;
}
const ov = overview!;
+8 -95
View File
@@ -23,17 +23,6 @@
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;
@@ -77,6 +66,7 @@
pointer-events: none;
}
/* 판 · 범례 · 값 목록 · 표 · 띠 알림 · 진행 막대는 부품 — 여기는 자리만 */
.e07-legend-slot {
position: absolute;
left: var(--spacing-8);
@@ -84,27 +74,12 @@
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-slot .e07-legend {
position: static;
}
.e07-legend__sub {
max-width: 180px;
color: var(--color-text-muted);
}
.e07-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e07-chip {
@@ -121,10 +96,7 @@
/* 좌측 */
.e07-hint,
.e07-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
margin-bottom: var(--spacing-8);
}
.e07-panel .ui-field[hidden] {
@@ -142,16 +114,6 @@
display: none;
}
.e07-progress {
width: 100%;
}
.e07-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e07-result {
padding: var(--spacing-12);
@@ -163,74 +125,25 @@
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;
.e07-result .ui-table th,
.e07-result .ui-table td {
padding: 0 4px;
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;
margin-top: var(--spacing-12);
}
.e07-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+28
View File
@@ -174,3 +174,31 @@ def test_e06_parts():
):
assert dead not in css, dead
assert "is-invalid" not in ts["Joints"] + ts["Page"]
def test_e07_parts():
page = _src("E07_StabArea/E07_StabArea_UI_Page.ts")
css = _src("E07_StabArea/E07_StabArea_UI_Style.css")
assert 'el("table"' not in page and 'el("progress"' not in page and 'el("p"' not in page
for part in (
"createSegment(",
"createFloatPanel(",
"createLegend(",
"createKv(",
"createTable(",
):
assert part in page, part
for part in ("createProgressBar()", "createBanner(", "createEmpty("):
assert part in page, part
assert (
'toggle.classList.add("e07-toggle")' in page
) # 촬영 장면이 `.e07-toggle button[data-value]` 로 잡음
assert "data-chainage" in css or "dataset.chainage" in page # 줄 누름 = 지도 이동
for dead in (
".e07-toggle .ui-btn",
".e07-legend__row",
".e07-summary",
".e07-table th",
".e07-progress {",
):
assert dead not in css, dead