refactor(E04): 보기 고르기 · 구간 줄 · 결과 값 목록 · 범례 판 · 진행 막대 · 띠 알림을 부품으로 (PLAN 553-4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:22:46 +09:00
co-authored by Claude Sonnet 5.5
parent 57ec6d247b
commit 3e4fcd8845
4 changed files with 130 additions and 190 deletions
+87 -64
View File
@@ -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<Station2d[]> {
}
}
/** 안내 글 한 줄 — 걸이 이름(`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!;
+6 -119
View File
@@ -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);
}
+9 -7
View File
@@ -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);
+28
View File
@@ -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