diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index e23acd3bb..2cee76c17 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -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!; diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css index 55436dfab..3ff7f6039 100644 --- a/E07_StabArea/E07_StabArea_UI_Style.css +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -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); } diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 9b1af02f5..059c95f01 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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