From 3e4fcd88450a64fabb5adfc4eae447da11d207d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:22:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor(E04):=20=EB=B3=B4=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EA=B5=AC=EA=B0=84=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B0=EA=B3=BC=20=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EB=B2=94=EB=A1=80=20=ED=8C=90=20=C2=B7=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EB=A7=89=EB=8C=80=20=C2=B7=20=EB=9D=A0=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20(PL?= =?UTF-8?q?AN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- E04_Stab3D/E04_Stab3D_UI_Page.ts | 151 ++++++++++++++---------- E04_Stab3D/E04_Stab3D_UI_Style.css | 125 +------------------- E04_Stab3D/E04_Stab3D_UI_View3D.ts | 16 +-- resources/tester/test_553_4_de_parts.py | 28 +++++ 4 files changed, 130 insertions(+), 190 deletions(-) diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index b7abbd9b8..e0a3dbbff 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -10,9 +10,18 @@ import "./E04_Stab3D_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 { createBanner } from "@ui/ui_template_banner"; import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; +import { createLegend } from "@ui/ui_template_flow_legend"; +import { createListRow } from "@ui/ui_template_list_row"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createProgressBar } from "@ui/ui_template_progress"; +import { createSegment } from "@ui/ui_template_segment"; import { createSidePanels } from "@ui/ui_template_side_panels"; +import { createText } from "@ui/ui_template_text"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map"; import { createView3D } from "./E04_Stab3D_UI_View3D"; @@ -86,18 +95,36 @@ async function fetch2d(projectId: string): Promise { } } +/** 안내 글 한 줄 — 걸이 이름(`e04-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("e04-empty"); + return node; +} + +/** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */ function legend(layer: MapLayer): HTMLElement { - const box = el("div", { className: "e04-legend" }); - box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") })); const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0); - for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) { - const item = el("span", { className: "e04-legend__row" }); - const swatch = el("i", { className: "e04-legend__swatch" }); - swatch.style.background = row.color; - item.append(swatch, el("span", { text: row.label })); - box.append(item); - } - return box; + const panel = createFloatPanel({ + title: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume"), + }); + panel.root.classList.add("e04-legend"); + panel.body.append( + createLegend( + [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }].map((row) => ({ + label: row.label, + color: row.color, + })), + { vertical: true }, + ), + ); + return panel.root; } export function renderE04Stab3D(root: HTMLElement): void { @@ -105,8 +132,7 @@ export function renderE04Stab3D(root: HTMLElement): void { const panel = el("div", { className: "ui-sidebar-panel e04-panel" }); const main = el("div", { className: "e04-page" }); mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel }); - if (!projectId) - return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") })); + if (!projectId) return void main.append(emptyBox(t("E04_NoProject"))); // ---- 가운데 ---- const map = createPlanMap(); @@ -115,19 +141,14 @@ export function renderE04Stab3D(root: HTMLElement): void { let mode: "plan" | "3d" = "plan"; let layer: MapLayer = "fs"; const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => { - const group = el("div", { className: "e04-toggle" }); - const buttons = labels.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", () => { - buttons.forEach((b) => b.classList.toggle("is-on", b === button)); - onPick(value); - }); - return button; + const group = createSegment({ + options: labels.map(([key, value]) => ({ value, label: t(key) })), + value: labels[0][1], + size: 24, + onChange: onPick, }); - group.append(...buttons); - return group; + group.root.classList.add("e04-toggle"); + return group.root; }; const legendSlot = el("div", { className: "e04-legend-slot" }); const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); @@ -183,8 +204,8 @@ export function renderE04Stab3D(root: HTMLElement): void { const sectionCard = createPanelCard(t("E04_Card_Sections"), false); const settings = buildSettingsCards(); sectionCard.append(sectionList, settings.rangeRow); - const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } }); - const progressText = el("p", { className: "e04-progress__text" }); + const progressBar = createProgressBar(); + const progressText = note("", "e04-progress__text"); const cancelButton = createButton({ label: t("E04_Cancel"), variant: "danger-ghost", @@ -192,7 +213,7 @@ export function renderE04Stab3D(root: HTMLElement): void { onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }), }); const progressBox = el("div", { className: "e04-progress-box" }); - progressBox.append(progressBar, progressText, cancelButton); + progressBox.append(progressBar.root, progressText, cancelButton); progressBox.hidden = true; const analyzeButton = createButton({ label: t("E04_Analyze"), @@ -207,10 +228,10 @@ export function renderE04Stab3D(root: HTMLElement): void { analyzeButton.disabled = reanalyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(reanalyzeButton, analyzeButton); - const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") }); + const readOnly = note(t("E04_ReadOnly"), "e04-hint"); readOnly.hidden = true; panel.append( - el("p", { className: "e04-hint", text: t("E04_Hint") }), + note(t("E04_Hint"), "e04-hint"), readOnly, sectionCard, settings.basicCard, @@ -258,31 +279,25 @@ export function renderE04Stab3D(root: HTMLElement): void { ...items.map((section) => { const key = sectionKey(section.start_m, section.end_m); const saved = savedOf.get(key); - const button = el("button", { - className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`, - attrs: { type: "button", "data-key": key }, + const row = createListRow({ + label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`, + trailing: saved + ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` + : "", + stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent", + active: key === sectionKey(start_m, end_m), + onClick: () => settings.setRange(section.start_m, section.end_m), }); - const chip = el("i", { className: "e04-section__chip" }); - chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent"; - button.append( - chip, - el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }), - el("span", { - className: "e04-section__value", - text: saved - ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` - : "", - }), - ); - button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m)); - return button; + row.classList.add("e04-section"); + row.dataset.key = key; + return row; }), ); } function paintResult(): void { if (!result) { - resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") })); + resultBody.replaceChildren(emptyBox(t("E04_Result_None"))); return; } const s = result.summary; @@ -319,20 +334,33 @@ export function renderE04Stab3D(root: HTMLElement): void { ["E04_Result_Trials", s.trials.toLocaleString()], ["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`], ]; - const list = el("dl", { className: "e04-result__list" }); - for (const [key, value, color] of rows) { - const dd = el("dd", { text: value, attrs: { "data-field": key } }); - if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`); + const kv: KvRow[] = rows.map(([key, value, color]) => { + const cell = document.createDocumentFragment(); if (color) { const chip = el("i", { className: "e04-section__chip" }); chip.style.background = color; - dd.prepend(chip); + cell.append(chip); } - list.append(el("dt", { text: t(key) }), dd); - } + cell.append(value); + return { + label: t(key), + value: cell, + ...(key === "E04_Result_Verdict" ? { ok: s.verdict === "ok" } : {}), + }; + }); + const list = createKv(kv); + list.classList.add("e04-result__list"); + list.querySelectorAll("dd").forEach((dd, i) => { + dd.setAttribute("data-field", rows[i][0]); + if (rows[i][0] === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`); + }); const notes: HTMLElement[] = []; - if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") })); - notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") })); + if (result.stale) { + const warn = createBanner({ tone: "warning", text: t("E04_Result_Stale") }); + warn.classList.add("e04-warn"); + notes.push(warn); + } + notes.push(note(t("E04_Result_Note"), "e04-note")); resultBody.replaceChildren(list, ...notes); } @@ -362,8 +390,8 @@ export function renderE04Stab3D(root: HTMLElement): void { if (!p) break; running = p.running; progressBox.hidden = !p.running; - progressBar.value = p.percent; - progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`; + progressBar.set(p.percent / 100); + progressText.replaceChildren(`${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`); paintButtons(); if (!p.running) { if (p.error) showToast(p.error, "error"); @@ -404,12 +432,7 @@ export function renderE04Stab3D(root: HTMLElement): void { try { await refresh(); } catch (error) { - main.replaceChildren( - el("p", { - className: "e04-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/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css index f215ec07a..3b1a7e7bd 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Style.css +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -29,17 +29,6 @@ flex-wrap: wrap; } -.e04-toggle { - display: inline-flex; - gap: var(--spacing-4); -} - -.e04-toggle .ui-btn.is-on { - border-color: var(--color-primary); - background: var(--color-primary); - color: var(--color-primary-text); -} - .e04-stage { position: relative; flex: 1 1 auto; @@ -99,23 +88,10 @@ right: var(--spacing-8); } +/* 판 · 범례 · 목록 줄 · 값 목록 · 띠 알림은 부품 — 여기는 자리만 */ .e04-view3d__tools { - position: absolute; top: var(--spacing-8); left: var(--spacing-8); - display: flex; - gap: var(--spacing-12); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font-size: 12px; -} - -.e04-view3d__toggle { - display: inline-flex; - align-items: center; - gap: var(--spacing-4); } .e04-legend-slot { @@ -125,30 +101,16 @@ pointer-events: none; } -.e04-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; +.e04-legend-slot .e04-legend { + position: static; } -.e04-legend__row { - display: flex; - align-items: center; - gap: var(--spacing-8); -} - -.e04-legend__swatch, .e04-section__chip { display: inline-block; width: 14px; height: 10px; flex: none; + margin-right: var(--spacing-4); border-radius: 2px; border: 1px solid var(--color-border); } @@ -156,10 +118,7 @@ /* 좌측 */ .e04-hint, .e04-note { - margin: 0 0 var(--spacing-8); - color: var(--color-text-muted); - font-size: 12px; - line-height: 1.5; + margin-bottom: var(--spacing-8); } .e04-sections { @@ -170,35 +129,6 @@ overflow: auto; } -.e04-section { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid transparent; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text); - font: inherit; - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.e04-section:hover { - background: var(--color-surface-raised); -} - -.e04-section.is-active { - border-color: var(--color-primary); -} - -.e04-section__value { - color: var(--color-text-muted); - font-size: 12px; -} - .e04-range .ui-field, .e04-advanced .ui-field { min-width: 0; @@ -229,58 +159,15 @@ display: none; } -.e04-progress { - width: 100%; -} - -.e04-progress__text { - margin: 0; - color: var(--color-text); - font-size: 12px; -} - /* 우측 */ .e04-result { padding: var(--spacing-12); } -.e04-result__list { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-8) var(--spacing-12); - margin: 0; - font-size: 13px; -} - -.e04-result__list dt { - color: var(--color-text-muted); -} - -.e04-result__list dd { - display: flex; - align-items: center; - gap: var(--spacing-8); - margin: 0; - color: var(--color-text); - font-weight: 600; - justify-content: flex-end; -} - -.e04-result__list dd.is-ok { - color: var(--color-success); -} - -.e04-result__list dd.is-ng { - color: var(--color-danger); -} - .e04-warn { - margin: var(--spacing-12) 0 0; - color: var(--color-danger); - font-size: 12px; + margin-top: var(--spacing-12); } .e04-empty { margin: var(--spacing-16); - color: var(--color-text-muted); } diff --git a/E04_Stab3D/E04_Stab3D_UI_View3D.ts b/E04_Stab3D/E04_Stab3D_UI_View3D.ts index 3986d8681..d750a3c38 100644 --- a/E04_Stab3D/E04_Stab3D_UI_View3D.ts +++ b/E04_Stab3D/E04_Stab3D_UI_View3D.ts @@ -9,7 +9,9 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createTerrainCompass } from "@ui/ui_template_compass"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { @@ -46,13 +48,13 @@ export function createView3D(): View3D { const canvas = el("canvas", { className: "e04-view3d__canvas" }); const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" }); // [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표 - const slipToggle = el("input", { attrs: { type: "checkbox" } }); - slipToggle.checked = true; - const tools = el("div", { className: "e04-view3d__tools" }); - const label = el("label", { className: "e04-view3d__toggle" }); - label.append(slipToggle, el("span", { text: t("E04_SlipSurface") })); - tools.append(label); - root.append(canvas, compass.root, tools); + const slipBox = createCheckbox(t("E04_SlipSurface"), { checked: true }); + const slipToggle = slipBox.input; + slipBox.root.classList.add("e04-view3d__toggle"); + const tools = createFloatPanel({ popover: true }); + tools.root.classList.add("e04-view3d__tools"); + tools.body.append(slipBox.root); + root.append(canvas, compass.root, tools.root); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio || 1); diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 79aa9dfa2..48cf90f58 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -105,3 +105,31 @@ def test_e03_parts(): ".e03-view3d {", ".e03-swatch-none", ".e03-list table", ".e03-list th {", ): # fmt: skip assert dead not in css, dead + + +def test_e04_parts(): + page = _src("E04_Stab3D/E04_Stab3D_UI_Page.ts") + view = _src("E04_Stab3D/E04_Stab3D_UI_View3D.ts") + css = _src("E04_Stab3D/E04_Stab3D_UI_Style.css") + assert 'el("progress"' not in page and "createProgressBar()" in page + for part in ( + "createSegment(", + "createListRow(", + "createKv(", + "createFloatPanel(", + "createBanner(", + ): + assert part in page, part + assert 'el("input"' not in view and "createCheckbox(" in view + assert ( + 'classList.add("e04-toggle")' in page + ) # 촬영 장면이 `.e04-toggle button[data-value]` 로 잡음 + assert 'row.classList.add("e04-section")' in page + for dead in ( + ".e04-toggle .ui-btn", + ".e04-legend__row", + ".e04-legend__swatch", + ".e04-result__list", + ".e04-section:hover", + ): + assert dead not in css, dead