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 "./E04_Stab3D_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; 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 { 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 { 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 { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { mountGroupStep } from "../A00_Common/group_workflow"; import { mountGroupStep } from "../A00_Common/group_workflow";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map"; import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D"; 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 { 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); 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 panel = createFloatPanel({
const item = el("span", { className: "e04-legend__row" }); title: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume"),
const swatch = el("i", { className: "e04-legend__swatch" }); });
swatch.style.background = row.color; panel.root.classList.add("e04-legend");
item.append(swatch, el("span", { text: row.label })); panel.body.append(
box.append(item); createLegend(
} [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }].map((row) => ({
return box; label: row.label,
color: row.color,
})),
{ vertical: true },
),
);
return panel.root;
} }
export function renderE04Stab3D(root: HTMLElement): void { 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 panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" }); const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel }); mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
if (!projectId) if (!projectId) return void main.append(emptyBox(t("E04_NoProject")));
return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") }));
// ---- 가운데 ---- // ---- 가운데 ----
const map = createPlanMap(); const map = createPlanMap();
@@ -115,19 +141,14 @@ export function renderE04Stab3D(root: HTMLElement): void {
let mode: "plan" | "3d" = "plan"; let mode: "plan" | "3d" = "plan";
let layer: MapLayer = "fs"; let layer: MapLayer = "fs";
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => { const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = el("div", { className: "e04-toggle" }); const group = createSegment({
const buttons = labels.map(([key, value], i) => { options: labels.map(([key, value]) => ({ value, label: t(key) })),
const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); value: labels[0][1],
button.dataset.value = value; size: 24,
button.classList.toggle("is-on", i === 0); onChange: onPick,
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(value);
});
return button;
}); });
group.append(...buttons); group.root.classList.add("e04-toggle");
return group; return group.root;
}; };
const legendSlot = el("div", { className: "e04-legend-slot" }); const legendSlot = el("div", { className: "e04-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); 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 sectionCard = createPanelCard(t("E04_Card_Sections"), false);
const settings = buildSettingsCards(); const settings = buildSettingsCards();
sectionCard.append(sectionList, settings.rangeRow); sectionCard.append(sectionList, settings.rangeRow);
const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } }); const progressBar = createProgressBar();
const progressText = el("p", { className: "e04-progress__text" }); const progressText = note("", "e04-progress__text");
const cancelButton = createButton({ const cancelButton = createButton({
label: t("E04_Cancel"), label: t("E04_Cancel"),
variant: "danger-ghost", variant: "danger-ghost",
@@ -192,7 +213,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }), onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }),
}); });
const progressBox = el("div", { className: "e04-progress-box" }); const progressBox = el("div", { className: "e04-progress-box" });
progressBox.append(progressBar, progressText, cancelButton); progressBox.append(progressBar.root, progressText, cancelButton);
progressBox.hidden = true; progressBox.hidden = true;
const analyzeButton = createButton({ const analyzeButton = createButton({
label: t("E04_Analyze"), label: t("E04_Analyze"),
@@ -207,10 +228,10 @@ export function renderE04Stab3D(root: HTMLElement): void {
analyzeButton.disabled = reanalyzeButton.disabled = true; analyzeButton.disabled = reanalyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" }); const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton); 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; readOnly.hidden = true;
panel.append( panel.append(
el("p", { className: "e04-hint", text: t("E04_Hint") }), note(t("E04_Hint"), "e04-hint"),
readOnly, readOnly,
sectionCard, sectionCard,
settings.basicCard, settings.basicCard,
@@ -258,31 +279,25 @@ export function renderE04Stab3D(root: HTMLElement): void {
...items.map((section) => { ...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m); const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key); const saved = savedOf.get(key);
const button = el("button", { const row = createListRow({
className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`, label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
attrs: { type: "button", "data-key": key }, 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" }); row.classList.add("e04-section");
chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent"; row.dataset.key = key;
button.append( return row;
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;
}), }),
); );
} }
function paintResult(): void { function paintResult(): void {
if (!result) { if (!result) {
resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") })); resultBody.replaceChildren(emptyBox(t("E04_Result_None")));
return; return;
} }
const s = result.summary; const s = result.summary;
@@ -319,20 +334,33 @@ export function renderE04Stab3D(root: HTMLElement): void {
["E04_Result_Trials", s.trials.toLocaleString()], ["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`], ["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
]; ];
const list = el("dl", { className: "e04-result__list" }); const kv: KvRow[] = rows.map(([key, value, color]) => {
for (const [key, value, color] of rows) { const cell = document.createDocumentFragment();
const dd = el("dd", { text: value, attrs: { "data-field": key } });
if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
if (color) { if (color) {
const chip = el("i", { className: "e04-section__chip" }); const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color; 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[] = []; const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") })); if (result.stale) {
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") })); 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); resultBody.replaceChildren(list, ...notes);
} }
@@ -362,8 +390,8 @@ export function renderE04Stab3D(root: HTMLElement): void {
if (!p) break; if (!p) break;
running = p.running; running = p.running;
progressBox.hidden = !p.running; progressBox.hidden = !p.running;
progressBar.value = p.percent; progressBar.set(p.percent / 100);
progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`; progressText.replaceChildren(`${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`);
paintButtons(); paintButtons();
if (!p.running) { if (!p.running) {
if (p.error) showToast(p.error, "error"); if (p.error) showToast(p.error, "error");
@@ -404,12 +432,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
try { try {
await refresh(); await refresh();
} catch (error) { } catch (error) {
main.replaceChildren( main.replaceChildren(emptyBox(error instanceof Error ? error.message : String(error)));
el("p", {
className: "e04-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
return; return;
} }
const ov = overview!; const ov = overview!;
+6 -119
View File
@@ -29,17 +29,6 @@
flex-wrap: wrap; 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 { .e04-stage {
position: relative; position: relative;
flex: 1 1 auto; flex: 1 1 auto;
@@ -99,23 +88,10 @@
right: var(--spacing-8); right: var(--spacing-8);
} }
/* 판 · 범례 · 목록 줄 · 값 목록 · 띠 알림은 부품 — 여기는 자리만 */
.e04-view3d__tools { .e04-view3d__tools {
position: absolute;
top: var(--spacing-8); top: var(--spacing-8);
left: 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 { .e04-legend-slot {
@@ -125,30 +101,16 @@
pointer-events: none; pointer-events: none;
} }
.e04-legend { .e04-legend-slot .e04-legend {
display: flex; position: static;
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__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e04-legend__swatch,
.e04-section__chip { .e04-section__chip {
display: inline-block; display: inline-block;
width: 14px; width: 14px;
height: 10px; height: 10px;
flex: none; flex: none;
margin-right: var(--spacing-4);
border-radius: 2px; border-radius: 2px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
@@ -156,10 +118,7 @@
/* 좌측 */ /* 좌측 */
.e04-hint, .e04-hint,
.e04-note { .e04-note {
margin: 0 0 var(--spacing-8); margin-bottom: var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
} }
.e04-sections { .e04-sections {
@@ -170,35 +129,6 @@
overflow: auto; 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-range .ui-field,
.e04-advanced .ui-field { .e04-advanced .ui-field {
min-width: 0; min-width: 0;
@@ -229,58 +159,15 @@
display: none; display: none;
} }
.e04-progress {
width: 100%;
}
.e04-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */ /* 우측 */
.e04-result { .e04-result {
padding: var(--spacing-12); 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 { .e04-warn {
margin: var(--spacing-12) 0 0; margin-top: var(--spacing-12);
color: var(--color-danger);
font-size: 12px;
} }
.e04-empty { .e04-empty {
margin: var(--spacing-16); margin: var(--spacing-16);
color: var(--color-text-muted);
} }
+9 -7
View File
@@ -9,7 +9,9 @@
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createTerrainCompass } from "@ui/ui_template_compass"; import { createTerrainCompass } from "@ui/ui_template_compass";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { el } from "@ui/ui_template_elements"; import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale"; import { t } from "@ui/ui_template_locale";
import { import {
@@ -46,13 +48,13 @@ export function createView3D(): View3D {
const canvas = el("canvas", { className: "e04-view3d__canvas" }); const canvas = el("canvas", { className: "e04-view3d__canvas" });
const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" }); const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" });
// [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표 // [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표
const slipToggle = el("input", { attrs: { type: "checkbox" } }); const slipBox = createCheckbox(t("E04_SlipSurface"), { checked: true });
slipToggle.checked = true; const slipToggle = slipBox.input;
const tools = el("div", { className: "e04-view3d__tools" }); slipBox.root.classList.add("e04-view3d__toggle");
const label = el("label", { className: "e04-view3d__toggle" }); const tools = createFloatPanel({ popover: true });
label.append(slipToggle, el("span", { text: t("E04_SlipSurface") })); tools.root.classList.add("e04-view3d__tools");
tools.append(label); tools.body.append(slipBox.root);
root.append(canvas, compass.root, tools); root.append(canvas, compass.root, tools.root);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio || 1); 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 {", ".e03-view3d {", ".e03-swatch-none", ".e03-list table", ".e03-list th {",
): # fmt: skip ): # fmt: skip
assert dead not in css, dead 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