From b037073b10c1484dccdb67a3edd17efb7e228fb3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:11:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?style(spreadsheet):=20=EC=85=80=20=EC=84=9C?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20=EB=A9=94=EB=AA=A8=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=ED=8C=A8=EB=84=90=20=EC=B0=BD=20=C2=B7=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=E2=9A=A0=20=EB=A5=BC=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=ED=83=AD=20=C2=B7=20=EB=96=A0?= =?UTF-8?q?=20=EC=9E=88=EB=8A=94=20=ED=8C=90=20=C2=B7=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EB=A1=9C=20(PLAN=20553-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 셀 서식 창 = 부품 모달(40rem) · 탭(32) · 칸 = 부품 입력 · 고르기 28 · 자체 판 · 탭 · 칸 CSS 지움 - 메모 편집 = 공용 확인창 판(.ui-confirm) · 글 칸 = 부품 입력 - 찾기 · 바꾸기 = 떠 있는 판(머리 없음) · 칸 24 · 단추 22 · 닫기 = 아이콘 단추 - 데이터 패널 창(조건부 서식 · 유효성 · 이름) = 떠 있는 판(머리 · 닫기 ✕) - 오류 ⚠ = 아이콘 단추(선 18) · 골라 붙여넣기 · 정렬 칸 · 알약 찾기 칸 = 부품 단추 · 고르기 · 입력 - 옛 보라 별칭 · 날 검정 가림막 → 공통 값(hover · accent · scrim) · 창 닫힘 감시가 모달도 기다림 - 날 단추 23 → 15(남음 = 수식 줄 칸 단추 · 격자 안 ▼ · 알약) · 자체 꼴 규칙 95 → 77 · 격자 색 전후 같음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/spreadsheet/spreadsheet.css | 72 +++++-------------- A00_Common/spreadsheet/spreadsheet_actions.ts | 9 ++- .../spreadsheet/spreadsheet_analysis.css | 16 ++--- .../spreadsheet/spreadsheet_data_menu.ts | 21 +++--- .../spreadsheet/spreadsheet_errcheck.ts | 10 ++- A00_Common/spreadsheet/spreadsheet_extras.ts | 8 +-- .../spreadsheet/spreadsheet_find_panel.ts | 43 +++++------ .../spreadsheet/spreadsheet_format_dialog.css | 61 +--------------- .../spreadsheet/spreadsheet_format_dialog.ts | 64 +++++++++-------- A00_Common/spreadsheet/spreadsheet_links.css | 13 +--- A00_Common/spreadsheet/spreadsheet_links.ts | 4 +- A00_Common/spreadsheet/spreadsheet_menu.ts | 8 +-- .../spreadsheet/spreadsheet_sort_dialog.ts | 7 +- .../spreadsheet/spreadsheet_sortfilter.css | 13 ++-- A00_Common/spreadsheet/spreadsheet_stage3.css | 4 +- .../spreadsheet/spreadsheet_toolbar.css | 39 ++-------- 16 files changed, 128 insertions(+), 264 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 4577256a9..a245b9474 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -109,7 +109,7 @@ bottom: 40px; z-index: 20; padding: 6px 10px; - border-radius: 4px; + border-radius: var(--radius-buttons); background: var(--color-primary); color: var(--color-primary-text); } @@ -126,6 +126,10 @@ cursor: pointer; } +.ss-brush:hover { + background: var(--color-hover); +} + .ss-brush.is-on { background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised)); box-shadow: inset 0 0 0 2px var(--color-focus-ring); @@ -135,19 +139,17 @@ cursor: copy; } -.ss-find { - position: absolute; +/* 찾기 = 떠 있는 판 부품(머리 없음) · 칸 · 단추 = 부품 — 여기는 자리 · 줄 배치만(553-6) */ +.ss-find.ui-float-panel { top: 4px; right: 16px; z-index: 20; +} + +.ss-find > .ui-float-panel__body { display: flex; flex-direction: column; gap: 4px; - padding: 8px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); } .ss-find[hidden], @@ -166,32 +168,17 @@ .ss-find__input { width: 180px; - padding: 2px 6px; - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text-body); - font: inherit; -} - -.ss-find__btn { - padding: 2px 8px; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-surface); - color: var(--color-text-body); - font: inherit; - cursor: pointer; } .ss-find__opt { display: flex; align-items: center; gap: 2px; - font-size: 12px; + font-size: var(--text-caption); } .ss-find__status { - font-size: 12px; + font-size: var(--text-caption); color: var(--color-text-secondary); } @@ -223,19 +210,11 @@ gap: 2px; padding: 4px; border: 1px solid var(--color-border); + border-radius: var(--radius-cards); background: var(--color-surface-raised); box-shadow: var(--shadow-lg); } -.ss-pick > button { - padding: 2px 8px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-body); - font: inherit; - cursor: pointer; -} - /* 함수 자동 완성 · 인자 도움말 */ .ss-hint { position: absolute; @@ -265,22 +244,17 @@ } .ss-hint__item.is-on { - background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised)); + background: var(--color-accent-soft); } /* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */ -.ss-dialog { - position: absolute; +/* 패널 창 = 떠 있는 판 부품 — 여기는 자리 · 크기만(553-6) */ +.ss-dialog.ui-float-panel { top: 4px; right: 16px; z-index: 21; max-width: min(720px, calc(100% - 32px)); max-height: calc(100% - 8px); - overflow: auto; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); } .ss-dialog[hidden], @@ -288,20 +262,6 @@ display: none; } -.ss-dialog__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 6px 8px; - border-bottom: 1px solid var(--color-border); - font-weight: bold; -} - -.ss-dialog__body { - padding: 8px; -} - .ss-list-arrow { position: absolute; z-index: 8; diff --git a/A00_Common/spreadsheet/spreadsheet_actions.ts b/A00_Common/spreadsheet/spreadsheet_actions.ts index a29be7fdc..8e1b654d3 100644 --- a/A00_Common/spreadsheet/spreadsheet_actions.ts +++ b/A00_Common/spreadsheet/spreadsheet_actions.ts @@ -118,10 +118,15 @@ export function dialogKeys(overlay: HTMLElement, close: () => void, run?: () => }); } -/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */ +/** body 에 뜬 창(.ui-confirm · 모달)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */ export function watchClose(back: () => void): void { const w = new MutationObserver(() => { - if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return; + if ( + document.querySelector( + "body > .ui-confirm, body > .ui-confirm__panel, body > .ui-modal__backdrop", + ) + ) + return; w.disconnect(); back(); }); diff --git a/A00_Common/spreadsheet/spreadsheet_analysis.css b/A00_Common/spreadsheet/spreadsheet_analysis.css index 31a7041dd..c0b6dd1be 100644 --- a/A00_Common/spreadsheet/spreadsheet_analysis.css +++ b/A00_Common/spreadsheet/spreadsheet_analysis.css @@ -63,7 +63,7 @@ display: flex; align-items: center; justify-content: center; - background: rgb(0 0 0 / 18%); + background: var(--color-scrim); } .ss-steps__box { @@ -115,18 +115,10 @@ overflow: visible; } -.ss-errtip__btn { +/* ⚠ = 아이콘 단추 부품(선 · 18) — 여기는 자리 · 경고 색만(553-6) */ +.ss-errtip__btn.ui-icon-btn { position: absolute; - width: 18px; - height: 18px; - padding: 0; - font-size: 11px; - line-height: 16px; - cursor: pointer; color: var(--color-warning); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); } .ss-errtip__balloon { @@ -183,5 +175,5 @@ } .ss-errtip__item:hover { - background: var(--color-mist-violet); + background: var(--color-hover); } diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index 3441b644e..695728089 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -12,6 +12,7 @@ import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임) import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { autoSumCells, autoSumRange } from "./spreadsheet_autosum"; import { isError } from "./spreadsheet_eval"; @@ -40,7 +41,6 @@ const TEXT = { validation: "데이터 유효성…", names: "이름 관리…", grid: "눈금선 보이기 · 숨기기", - close: "닫기", list: "값 목록(Alt+↓)", sheets: "시트 숨기기 · 탭 색…", audit: "수식 분석", @@ -121,18 +121,14 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe }; // ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ───────────────────── - const title = el("span", { className: "ss-dialog__title" }); - const body = el("div", { className: "ss-dialog__body" }); - const shut = el("button", { - className: "ss-find__btn", - text: "×", - attrs: { type: "button", title: TEXT.close }, - }); - const dialog = el("div", { - className: "ss-dialog ui-popup", - attrs: { hidden: "" }, - children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], + // 떠 있는 판 부품(머리 · 닫기 ✕ · 몸 · 553-6) — 자리는 `.ss-dialog` + const { root: dialog, body } = createFloatPanel({ + title: " ", + onClose: () => (closePanel(), back()), }); + dialog.classList.add("ss-dialog"); + dialog.setAttribute("hidden", ""); + const title = dialog.querySelector(".ui-float-panel__title") as HTMLElement; ctx.root.append(dialog); let panel: PartHandle | null = null; const closePanel = (): void => { @@ -141,7 +137,6 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe body.replaceChildren(); dialog.setAttribute("hidden", ""); }; - shut.addEventListener("click", () => (closePanel(), back())); dialog.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; e.stopPropagation(); diff --git a/A00_Common/spreadsheet/spreadsheet_errcheck.ts b/A00_Common/spreadsheet/spreadsheet_errcheck.ts index b46422341..103f91131 100644 --- a/A00_Common/spreadsheet/spreadsheet_errcheck.ts +++ b/A00_Common/spreadsheet/spreadsheet_errcheck.ts @@ -11,7 +11,7 @@ * 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createIconButton, el } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { toA1 } from "./spreadsheet_address"; import { isError } from "./spreadsheet_eval"; @@ -137,11 +137,9 @@ export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): Er const g = ctx.grid.root.getBoundingClientRect(); layer.style.left = `${box.x - (a.left - g.left) - 20}px`; layer.style.top = `${box.y - (a.top - g.top)}px`; - const btn = el("button", { - className: "ss-errtip__btn", - text: "⚠", - attrs: { type: "button", "aria-label": w.까닭 }, - }); + const btn = createIconButton({ glyph: "⚠", title: w.까닭, variant: "outline", size: 18 }); + btn.classList.add("ss-errtip__btn"); + btn.removeAttribute("title"); // 까닭은 옆 풍선이 보임 const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 }); btn.addEventListener("mousedown", (ev) => ev.stopPropagation()); btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c))); diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts index ccf2db0c6..437ce0520 100644 --- a/A00_Common/spreadsheet/spreadsheet_extras.ts +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -6,7 +6,7 @@ * 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { watchClose } from "./spreadsheet_actions"; import { parseClipboard } from "./spreadsheet_clipboard"; import { attachComments, getComment, setComment } from "./spreadsheet_comments"; @@ -138,10 +138,8 @@ export function attachExtras( : undefined; picker.replaceChildren( ...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => { - const b = el("button", { - text: TEXT.modes[mode], - attrs: { type: "button", "data-mode": mode }, - }); + const b = createButton({ label: TEXT.modes[mode], variant: "ghost", size: 22 }); + b.dataset.mode = mode; b.addEventListener("click", () => { picker.setAttribute("hidden", ""); // 늦게 — 크기 예산 diff --git a/A00_Common/spreadsheet/spreadsheet_find_panel.ts b/A00_Common/spreadsheet/spreadsheet_find_panel.ts index 985ffd1bd..d2894ad96 100644 --- a/A00_Common/spreadsheet/spreadsheet_find_panel.ts +++ b/A00_Common/spreadsheet/spreadsheet_find_panel.ts @@ -4,7 +4,8 @@ * Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀). * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, createIconButton, el, setUiSize } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find"; import { selectCell } from "./spreadsheet_selection"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -29,12 +30,14 @@ export interface FindPanelHandle extends PartHandle { } export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle { - const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } }); - const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } }); + const field = (placeholder: string): HTMLInputElement => + setUiSize(el("input", { className: "ui-input ss-find__input", attrs: { placeholder } }), 24); + const query = field(TEXT.find); + const repl = field(TEXT.replace); const status = el("span", { className: "ss-find__status" }); const button = (text: string, onClick: () => void): HTMLButtonElement => { - const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } }); - b.addEventListener("click", onClick); + const b = createButton({ label: text, variant: "ghost", size: 22, onClick }); + b.classList.add("ss-find__btn"); return b; }; const check = (text: string): HTMLInputElement => { @@ -42,8 +45,8 @@ export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindP options.append(el("label", { className: "ss-find__opt", children: [box, text] })); return box; }; - const closeBtn = button("×", close); - closeBtn.title = TEXT.close; + const closeBtn = createIconButton({ glyph: "×", title: TEXT.close, size: 22, onClick: close }); + closeBtn.classList.add("ss-find__btn"); const options = el("div", { className: "ss-find__row" }); const caseBox = check(TEXT.case); const wholeBox = check(TEXT.whole); @@ -52,19 +55,19 @@ export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindP className: "ss-find__row", children: [repl, button(TEXT.one, one), button(TEXT.all, all)], }); - const root = el("div", { - className: "ss-find", - attrs: { hidden: "" }, - children: [ - el("div", { - className: "ss-find__row", - children: [query, button(TEXT.next, next), closeBtn], - }), - replaceRow, - options, - status, - ], - }); + // 떠 있는 판 부품(머리 없음 · 553-6) — 자리는 `.ss-find` + const { root, body } = createFloatPanel({ popover: true }); + root.classList.add("ss-find"); + root.setAttribute("hidden", ""); + body.append( + el("div", { + className: "ss-find__row", + children: [query, button(TEXT.next, next), closeBtn], + }), + replaceRow, + options, + status, + ); ctx.root.append(root); const opts = (): FindOptions => ({ diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.css b/A00_Common/spreadsheet/spreadsheet_format_dialog.css index 64cf66693..8f8730213 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.css +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.css @@ -1,50 +1,9 @@ /* ============================================================================= * spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1) - * 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀. + * 셀 서식 대화상자 — 창 · 탭 · 칸 = 부품 모달(40rem) · 탭(32) · 입력 · 고르기(28) · 여기는 안 배치만(553-6). * 색 · 간격은 테마 토큰만(`ui_template_theme.css`). * ========================================================================== */ -.ss-fmtdlg__panel { - display: flex; - flex-direction: column; - width: min(560px, 92vw); - max-height: 84vh; - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} - -.ss-fmtdlg__title { - margin: 0 0 var(--spacing-8); - font-size: 1rem; - color: var(--color-text-body); -} - -.ss-fmtdlg__tabs { - display: flex; - flex-wrap: wrap; - gap: 2px; - border-bottom: 1px solid var(--color-border); -} - -.ss-fmtdlg__tabbtn { - padding: 6px 12px; - border: 1px solid var(--color-border); - border-bottom: 0; - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - background: var(--color-paper); - color: var(--color-text-secondary); - font-size: 0.82rem; - cursor: pointer; -} - -.ss-fmtdlg__tabbtn.is-active { - background: var(--color-canvas); - color: var(--color-text-body); - font-weight: 600; -} - .ss-fmtdlg__body { flex: 1; min-height: 300px; @@ -68,21 +27,7 @@ width: 140px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 0.82rem; -} - -.ss-fmtdlg__row select, -.ss-fmtdlg__row input[type="text"], -.ss-fmtdlg__row input:not([type]), -.ss-fmtdlg__row input[type="number"], -.ss-fmtdlg__code { - height: 28px; - padding: 0 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font-size: 0.82rem; + font-size: var(--text-body-sm); } .ss-fmtdlg__code { @@ -135,7 +80,7 @@ .ss-fmtdlg__note { margin: 0 0 var(--spacing-8); color: var(--color-text-muted); - font-size: 0.8rem; + font-size: var(--text-caption); } .ss-fmtdlg__actions { diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index ce97d678d..e4c2d2442 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -12,7 +12,9 @@ * 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`). * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, el, setUiSize } from "@ui/ui_template_elements"; +import { createTabs } from "@ui/ui_template_tabs"; +import { openModal } from "@ui/ui_template_modal"; import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; import { @@ -39,6 +41,7 @@ const TEXT = { tabProtect: "보호", ok: "확인", cancel: "취소", + close: "닫기", category: "범주", decimals: "소수 자릿수", thousands: "천 단위 구분 기호 사용", @@ -105,17 +108,9 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1; let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); - const overlay = el("div", { className: "ui-confirm ss-fmtdlg" }); - const panel = el("div", { className: "ss-fmtdlg__panel" }); const tabsBar = el("div", { className: "ss-fmtdlg__tabs" }); const body = el("div", { className: "ss-fmtdlg__body" }); - overlay.append(panel); - panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body); - - const close = (): void => overlay.remove(); - overlay.addEventListener("click", (ev) => { - if (ev.target === overlay) close(); - }); + let close = (): void => {}; // ── 표시 형식 ──────────────────────────────────────────────────────── let category: Category = guessCategory(draft.형식); @@ -464,22 +459,25 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { [TEXT.tabProtect, protectTab], ]; - function showTab(index: number): void { - body.replaceChildren(tabs[index][1]()); - tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => { - btn.classList.toggle("is-active", i === index); - }); - } - - tabs.forEach(([label], i) => { - const btn = el("button", { - className: "ss-fmtdlg__tabbtn", - text: label, - attrs: { type: "button" }, - }); - btn.addEventListener("click", () => showTab(i)); - tabsBar.append(btn); + const tabBar = createTabs({ + tabs: tabs.map(([label], i) => ({ key: String(i), label })), + value: "0", + size: 32, + onChange: (key) => showTab(Number(key)), }); + tabsBar.append(tabBar.root); + + function showTab(index: number): void { + const tab = tabs[index][1](); + // 칸 = 부품 입력 · 고르기 28(553-6) + const fields = "select, input:not([type]), input[type=text], input[type=number]"; + for (const f of tab.querySelectorAll(fields)) { + f.classList.add(f.tagName === "SELECT" ? "ui-select" : "ui-input"); + setUiSize(f, 28); + } + body.replaceChildren(tab); + tabBar.select(String(index)); + } function apply(): void { ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: draft }); @@ -497,13 +495,21 @@ export function openFormatDialog(ctx: SpreadsheetContext): void { const actions = el("div", { className: "ss-fmtdlg__actions", children: [ - createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => close() }), createButton({ label: TEXT.ok, variant: "filled", onClick: apply }), ], }); - panel.append(actions); showTab(0); - document.body.append(overlay); - dialogKeys(overlay, close, apply); + openModal({ + title: TEXT.title, + closeLabel: TEXT.close, + width: "40rem", + dialogClass: "ss-fmtdlg", + mount: (box, shut) => { + close = shut; + box.append(tabsBar, body, actions); + dialogKeys(box.parentElement ?? box, shut, apply); + }, + }); } diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 185a59526..f1087d6a5 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -120,20 +120,11 @@ width: 320px; padding: var(--spacing-8); border: 1px solid var(--color-border); - border-radius: var(--radius-sm); + border-radius: var(--radius-cards); background: var(--color-surface-raised); box-shadow: var(--shadow-lg); } -.ss-links__search { - padding: 4px 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; -} - .ss-links__list { display: flex; flex-direction: column; @@ -155,7 +146,7 @@ } .ss-links__row:hover { - background: var(--color-mist-violet); + background: var(--color-hover); } .ss-links__note { diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 6fec08291..17f0229d3 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -585,8 +585,8 @@ export function mountLinks( function openPick(anchor: HTMLElement): void { closePick(); const input = el("input", { - className: "ss-links__search", - attrs: { type: "search", placeholder: TEXT.search }, + className: "ui-input ss-links__search", + attrs: { type: "search", placeholder: TEXT.search, "data-size": "28" }, }) as HTMLInputElement; const list = el("div", { className: "ss-links__list" }); const note = (text: string) => el("div", { className: "ss-links__note", text }); diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index c47a50612..14ea72675 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -59,15 +59,15 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 * 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */ function openCommentEditor(initial: string, onSave: (text: string) => void): void { - const overlay = el("div", { className: "ss-comment-editor" }); - const panel = el("div", { className: "ss-comment-editor__panel" }); + const overlay = el("div", { className: "ui-confirm ss-comment-editor" }); + const panel = el("div", { className: "ui-confirm__panel" }); const textarea = el("textarea", { - className: "ss-comment-editor__input", + className: "ui-input ss-comment-editor__input", }) as HTMLTextAreaElement; textarea.value = initial; const done = (): void => overlay.remove(); const actions = el("div", { - className: "ss-comment-editor__actions", + className: "ui-confirm__actions", children: [ createButton({ label: "취소", variant: "ghost", onClick: done }), createButton({ diff --git a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts index 92effb8cd..e4fb8185c 100644 --- a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts @@ -100,12 +100,17 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void { function drawKeys() { list.replaceChildren( ...keys.map((k, i) => { - const col = el("select", { attrs: { "aria-label": t("Column") } }); + const col = el("select", { + className: "ui-select", + attrs: { "aria-label": t("Column"), "data-size": "28" }, + }); for (let c = rg.c0; c <= rg.c1; c++) col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } })); col.value = String(k.열); col.addEventListener("change", () => (k.열 = Number(col.value))); const dir = el("select", { + className: "ui-select", + attrs: { "data-size": "28" }, children: [ el("option", { text: t("Asc"), attrs: { value: "asc" } }), el("option", { text: t("Desc"), attrs: { value: "desc" } }), diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.css b/A00_Common/spreadsheet/spreadsheet_sortfilter.css index 36244d642..821104eac 100644 --- a/A00_Common/spreadsheet/spreadsheet_sortfilter.css +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.css @@ -7,7 +7,7 @@ display: flex; align-items: center; justify-content: center; - background: rgb(0 0 0 / 18%); + background: var(--color-scrim); } .ss-sort__box, @@ -48,11 +48,6 @@ .ss-sort__row select { flex: 1; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: inherit; } .ss-sort__row option { @@ -92,8 +87,8 @@ } .ss-filter__btn.is-on { - color: var(--color-royal-amethyst); - border-color: var(--color-royal-amethyst); + color: var(--color-accent); + border-color: var(--color-accent); } .ss-filter__panel { @@ -130,5 +125,5 @@ } .ss-filter__link:hover { - background: var(--color-mist-violet); + background: var(--color-hover); } diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css index e50db4efe..2d394bd59 100644 --- a/A00_Common/spreadsheet/spreadsheet_stage3.css +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -14,7 +14,7 @@ padding: var(--spacing-8); background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 8px; + border-radius: var(--radius-cards); min-width: 280px; } @@ -89,7 +89,7 @@ } .ss-validation__dropdown-item:hover { - background: var(--color-mist-violet); + background: var(--color-hover); } .ss-statusbar { diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index 92606abe7..c5c3cb80f 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -215,42 +215,13 @@ /* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */ -.ss-comment-editor { - position: fixed; - inset: 0; - z-index: var(--z-modal); - display: flex; - align-items: center; - justify-content: center; - background: var(--color-scrim); -} - -.ss-comment-editor__panel { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - width: 280px; - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} - +/* 창 = 공용 확인창(.ui-confirm) · 칸 = 부품 입력(여러 줄) — 여기는 칸 크기만(553-6) */ .ss-comment-editor__input { + display: block; + box-sizing: border-box; + width: 100%; min-height: 80px; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font: inherit; - resize: vertical; -} - -.ss-comment-editor__actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); + margin-bottom: var(--spacing-16); } /* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */ From a5bde66bd391f11f44fac1d7cf50b893bb962e3e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:17:44 +0900 Subject: [PATCH 2/4] =?UTF-8?q?style(cad=5Fhost):=20CAD=20=ED=83=AD=20?= =?UTF-8?q?=C3=97=20=C2=B7=20+=20=EB=A5=BC=20=EC=95=84=EC=9D=B4=EC=BD=98?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EA=BC=B4=EB=A1=9C=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EA=B0=81=20=ED=8E=B8=EC=A7=91=20=EB=8B=A8=EC=B6=94=2024=20?= =?UTF-8?q?=C2=B7=20=EB=82=A0=20=EA=B7=B8=EB=A6=BC=EC=9E=90=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EB=9D=BC=20=EB=B3=84=EC=B9=AD=20=E2=86=92=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EA=B0=92=20(PLAN=20553-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탭 줄 × = 아이콘 단추(18) · + = 아이콘 단추(22) — 문서 탭 꼴은 그대로 · 자체 단추 CSS 지움 - 도각 편집 띠 [완료 · 파일 불러오기 · 기본 도각 복원 · 취소] = 부품 단추 24(자체 여백 · 글 크기 지움) - 불러오는 중 상자 그림자 = --shadow-lg · 오류 색 날 값 지움 · 시트 표(ui-sheet) 켬 색 · 안내 글 = 공통 값 - 시험 test_cad_host_tabs 가 클래스 여럿 붙은 단추를 찾게 고침 · B07 전후 촬영 CAD 안 배치 0 바뀜 · 바탕 색 같음 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_cad_host_tabs.py | 6 +++--- ui_template/cad_host/cad_host.css | 10 ++++------ ui_template/cad_host/cad_host_frame_edit.ts | 4 ++++ ui_template/cad_host/cad_host_tabs.css | 20 +------------------- ui_template/cad_host/cad_host_tabs.ts | 6 ++++-- ui_template/sheet/ui_template_sheet.css | 4 ++-- 6 files changed, 18 insertions(+), 32 deletions(-) diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index bcffbcd31..5efd4d11c 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -81,7 +81,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음) // ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음 await tabs.select("c"); - const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x"); + const xc = tabNode("c").kids.find((k) => k.className.split(" ").includes("cad-tabs__x")); await xc.on.click({ stopPropagation() {} }); out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length }; // ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음 @@ -97,7 +97,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template await closer.open({ id: "d1", name: "종단" }); const before = confirms.length; const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2"); - await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} }); + await d2.kids.find((k) => k.className.split(" ").includes("cad-tabs__x")).on.click({ stopPropagation() {} }); const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음 out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last }; @@ -137,7 +137,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template }); await new Promise((r) => setTimeout(r, 5)); const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs"); - const plus = bar.kids.find((k) => k.className === "cad-tabs__add"); + const plus = bar.kids.find((k) => k.className.split(" ").includes("cad-tabs__add")); await plus.on.click(); await new Promise((r) => setTimeout(r, 5)); inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림 diff --git a/ui_template/cad_host/cad_host.css b/ui_template/cad_host/cad_host.css index 282198718..9b53aff16 100644 --- a/ui_template/cad_host/cad_host.css +++ b/ui_template/cad_host/cad_host.css @@ -24,7 +24,7 @@ padding: 10px 18px; border-radius: var(--radius-cards); background-color: var(--color-surface); - box-shadow: 0 2px 10px rgb(0 0 0 / 18%); + box-shadow: var(--shadow-lg); color: var(--color-text); font-size: 13px; } @@ -38,10 +38,10 @@ transform: translateX(-50%); max-width: 70%; padding: 8px 16px; - border: 1px solid var(--color-danger, #d33); + border: 1px solid var(--color-danger); border-radius: var(--radius-cards); background-color: var(--color-surface); - color: var(--color-danger, #d33); + color: var(--color-danger); font-size: 12px; } @@ -86,7 +86,7 @@ } .cad-frame-edit__label { - font-size: 0.78rem; + font-size: var(--text-caption); line-height: 1.4; color: var(--color-text); } @@ -99,8 +99,6 @@ .cad-frame-edit__buttons > button { flex: 1 1 0; min-width: 0; - padding: 4px 8px; - font-size: 0.78rem; } /* 작도 영역 숫자 칸 넷(169-1 · `cad_host_area.ts`) — 두 줄 두 칸 */ diff --git a/ui_template/cad_host/cad_host_frame_edit.ts b/ui_template/cad_host/cad_host_frame_edit.ts index ec2f12eba..4a110fd98 100644 --- a/ui_template/cad_host/cad_host_frame_edit.ts +++ b/ui_template/cad_host/cad_host_frame_edit.ts @@ -86,6 +86,7 @@ export function createFrameTemplateEditor( const finishButton = createButton({ label: "완료", variant: "filled", + size: 24, onClick: () => void finish(), }); /** @@ -95,6 +96,7 @@ export function createFrameTemplateEditor( const resetButton = createButton({ label: "기본 도각 복원", variant: "ghost", + size: 24, onClick: () => void resetToDefault(), }); /** @@ -111,12 +113,14 @@ export function createFrameTemplateEditor( const importButton = createButton({ label: "파일 불러오기", variant: "ghost", + size: 24, onClick: () => fileInput.click(), }); const cancelButton = createButton({ label: "취소", variant: "ghost", + size: 24, onClick: () => leave(), }); const bannerButtons = document.createElement("div"); diff --git a/ui_template/cad_host/cad_host_tabs.css b/ui_template/cad_host/cad_host_tabs.css index abc753155..0efc12b83 100644 --- a/ui_template/cad_host/cad_host_tabs.css +++ b/ui_template/cad_host/cad_host_tabs.css @@ -66,28 +66,10 @@ font: inherit; } -.cad-tabs__x, -.cad-tabs__add { - padding: 0 4px; - border: none; - border-radius: 4px; - background: transparent; - color: var(--color-text-secondary); - font-size: 14px; - line-height: 1; - cursor: pointer; -} - -.cad-tabs__x:hover, -.cad-tabs__add:hover { - background: var(--color-surface-raised); - color: var(--color-text); -} - +/* × · + = 아이콘 단추 부품(18 · 22) — 여기는 자리만(553-6) */ .cad-tabs__add { align-self: center; margin-left: 2px; - font-size: 16px; } .cad-tabs[data-busy="true"] .cad-tabs__tab { diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts index 9140d49fc..a40b6c1c4 100644 --- a/ui_template/cad_host/cad_host_tabs.ts +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -233,7 +233,8 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { if ((features.remove || (features.close && list.length > 1)) && !tab.fixed) { const x = document.createElement("button"); x.type = "button"; - x.className = "cad-tabs__x"; + x.className = "ui-icon-btn ui-icon-btn--ghost cad-tabs__x"; // 아이콘 단추 부품 꼴(553-6) + x.dataset.size = "18"; x.textContent = "×"; x.title = x.ariaLabel = features.remove ? text.remove : text.close; x.addEventListener("click", (event) => { @@ -272,7 +273,8 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { if (features.add) { const plus = document.createElement("button"); plus.type = "button"; - plus.className = "cad-tabs__add"; + plus.className = "ui-icon-btn ui-icon-btn--ghost cad-tabs__add"; + plus.dataset.size = "22"; plus.textContent = "+"; plus.title = plus.ariaLabel = text.add; plus.disabled = busy; diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index e9163a6f1..e100d1634 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -18,13 +18,13 @@ } .ui-sheet__toolbar .ui-btn.is-on { - background: var(--color-mist-violet); + background: var(--color-accent-soft); } .ui-sheet__hint { margin: 0; color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */ - font-size: 0.8rem; + font-size: var(--text-caption); } .ui-sheet__scroll { From 57ec6d247b641053664d3d0717ccae3e23ce805e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:19:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(E03):=20=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=EB=B3=B4=EC=A1=B0=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=C2=B7=20=EB=B2=94=EB=A1=80=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EB=9E=A8=ED=94=84=20=C2=B7=20=EB=B3=84=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=C2=B7=20=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?3D=20=EC=B0=BD=EC=9D=84=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20(PLAN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- E03_StabSection/E03_StabSection_UI_List.ts | 141 ++++++----- E03_StabSection/E03_StabSection_UI_Page.ts | 43 +--- E03_StabSection/E03_StabSection_UI_Parts.ts | 54 +++++ E03_StabSection/E03_StabSection_UI_Plan.ts | 12 +- E03_StabSection/E03_StabSection_UI_Prob.css | 6 + E03_StabSection/E03_StabSection_UI_Prob.ts | 115 ++++----- .../E03_StabSection_UI_ProbChart.ts | 21 +- .../E03_StabSection_UI_Reinforce.ts | 126 ++++------ E03_StabSection/E03_StabSection_UI_Result.ts | 147 ++++++----- E03_StabSection/E03_StabSection_UI_Section.ts | 53 ++-- E03_StabSection/E03_StabSection_UI_Style.css | 228 ++---------------- E03_StabSection/E03_StabSection_UI_Support.ts | 96 +++----- E03_StabSection/E03_StabSection_UI_Table.ts | 43 ++++ E03_StabSection/E03_StabSection_UI_View3D.ts | 57 ++--- E03_StabSection/E03_StabSection_UI_Walls.css | 2 +- resources/tester/test_553_4_de_parts.py | 23 ++ 16 files changed, 503 insertions(+), 664 deletions(-) create mode 100644 E03_StabSection/E03_StabSection_UI_Parts.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Table.ts diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 6a810878a..9cfcf82ed 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -4,8 +4,12 @@ * 대표 단면 별(자동 = 채움 · 사용자 켬/끔 = 밑줄 · 누르면 바꿈). 정렬 · 거름은 logic `listRows`. * ========================================================================== */ +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createIconButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { e03Table } from "./E03_StabSection_UI_Table"; import { listRows, roleText, @@ -30,91 +34,86 @@ export interface ListDraw { editable: boolean; } +const VERDICT_TONE: Record = { ok: "success", ng: "danger", none: "neutral" }; + export function buildList( draw: ListDraw, onSelect: (key: string) => void, onToggle: (key: string) => void, ): HTMLElement { const box = el("div", { className: "e03-list" }); - const table = el("table", { attrs: { "data-role": "station-list" } }); - const head = el("tr"); - head.append( - el("th", { text: t("E03_Col_Station") }), - el("th", { text: t("E03_Col_Role") }), - ...draw.conditions.map((c) => - el("th", { - className: "is-num", - text: t(`E03_Cond_${c}` as LocaleKey), - attrs: c === draw.condition ? { "aria-sort": "other" } : {}, - }), - ), - el("th", { text: t("E03_Verdict") }), - el("th", { text: t("E03_Col_Rep"), attrs: { title: t("E03_Rep_Hint") } }), - ); - table.append(el("thead", { children: [head] })); - const body = el("tbody"); const rows = listRows(draw.rows, draw.condition, draw.sort, draw.ngOnly); - for (const row of rows) { - const tr = el("tr", { - className: row.key === draw.active ? "is-active" : "", - attrs: { "data-key": row.key, "data-verdict": verdictOf(row, draw.condition) }, - }); + const columns = [ + { label: t("E03_Col_Station") }, + { label: t("E03_Col_Role") }, + ...draw.conditions.map((c) => ({ label: t(`E03_Cond_${c}` as LocaleKey), num: true })), + { label: t("E03_Verdict") }, + { label: t("E03_Col_Rep"), title: t("E03_Rep_Hint") }, + ]; + const cells = rows.map((row) => { const v = verdictOf(row, draw.condition); const rep = draw.representative(row); - const star = el("button", { - className: `e03-star${rep.on ? " is-on" : ""}${rep.user !== null ? " is-user" : ""}`, - text: rep.on ? "★" : "☆", - attrs: { - type: "button", - title: - [ - ...(row.representative?.auto ?? []).map((r) => t(`E03_Rep_${r}` as LocaleKey)), - rep.user !== null ? t("E03_Rep_User") : "", - ] - .filter(Boolean) - .join(" · ") || t("E03_Rep_Hint"), - "aria-pressed": String(rep.on), + const title = + [ + ...(row.representative?.auto ?? []).map((r) => t(`E03_Rep_${r}` as LocaleKey)), + rep.user !== null ? t("E03_Rep_User") : "", + ] + .filter(Boolean) + .join(" · ") || t("E03_Rep_Hint"); + const star = createIconButton({ + glyph: rep.on ? "★" : "☆", + title, + size: 20, + pressed: rep.on, + disabled: !draw.editable, + onClick: (ev) => { + ev.stopPropagation(); + onToggle(row.key); }, }); - star.disabled = !draw.editable; - star.addEventListener("click", (ev) => { - ev.stopPropagation(); - onToggle(row.key); - }); - tr.append( - el("td", { - text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, - children: row.walls - ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] - : [], - }), - el("td", { - text: row.sides.length - ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) - : "-", - }), - ...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })), - el("td", { - children: [ - el("span", { - className: `e03-verdict e03-verdict--${v}`, - text: row.error - ? t("E03_Error") - : v === "none" - ? "-" - : t(`E03_Verdict_${v}` as LocaleKey), - }), - ], - }), - el("td", { children: [star] }), + star.classList.add("e03-star"); + star.classList.toggle("is-on", rep.on); + star.classList.toggle("is-user", rep.user !== null); + const verdict = createBadge( + row.error ? t("E03_Error") : v === "none" ? "-" : t(`E03_Verdict_${v}` as LocaleKey), + VERDICT_TONE[v], + { kind: "lamp" }, ); + verdict.classList.add("e03-verdict", `e03-verdict--${v}`); + const station = el("span", { + text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + children: row.walls + ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] + : [], + }); + return [ + station, + row.sides.length ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) : "-", + ...draw.conditions.map((c) => fmt(row.min[c])), + verdict, + star, + ]; + }); + const list = e03Table(columns, cells, "station-list"); + list.table.tHead?.rows[0].cells + .item(2 + draw.conditions.indexOf(draw.condition)) + ?.setAttribute("aria-sort", "other"); + list.rows.forEach((tr, i) => { + const row = rows[i]; + tr.dataset.key = row.key; + tr.dataset.verdict = verdictOf(row, draw.condition); + tr.classList.toggle("is-active", row.key === draw.active); if (row.error) tr.title = row.error; tr.addEventListener("click", () => onSelect(row.key)); - body.append(tr); - } - table.append(body); - box.append(table); - if (!rows.length) box.append(el("p", { className: "e03-empty", text: t("E03_ListEmpty") })); + }); + box.append(list.root); + if (!rows.length) box.append(hookEmpty(t("E03_ListEmpty"))); queueMicrotask(() => box.querySelector("tr.is-active")?.scrollIntoView({ block: "nearest" })); return box; } + +function hookEmpty(text: string): HTMLElement { + const empty = createEmpty({ text, small: true }); + empty.classList.add("e03-empty"); + return empty; +} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index e458e70f4..b7baa1711 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -25,6 +25,7 @@ import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; import { buildPlan } from "./E03_StabSection_UI_Plan"; +import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts"; import { buildResult } from "./E03_StabSection_UI_Result"; import { buildLegends, @@ -86,20 +87,12 @@ async function api(path: string, init?: RequestInit): Promise { const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement { - const input = el("input", { attrs: { type: "checkbox" } }); - input.checked = checked; - input.addEventListener("change", () => onChange(input.checked)); - return el("label", { className: "e03-check", children: [input, label] }); -} - export function renderE03StabSection(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e03-page" }); const panel = el("div", { className: "ui-sidebar-panel e03-panel" }); mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel }); - if (!projectId) - return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") })); + if (!projectId) return void main.append(emptyBox(t("E03_NoProject"))); const pid: string = projectId; const base = `/projects/${pid}/stab`; const prob = createProbPanel(pid, base, () => paintSection()); @@ -146,11 +139,11 @@ export function renderE03StabSection(root: HTMLElement): void { paintList(); }, }); - const ngBox = checkbox(t("E03_NgOnly"), false, (v) => { + const ngBox = check(t("E03_NgOnly"), false, (v) => { ngOnly = v; paintList(); }); - const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } }); + const status = muted("", "status"); const listHost = el("div", { className: "e03-panel", attrs: { style: "flex: 1 1 auto; min-height: 0" }, @@ -180,7 +173,7 @@ export function renderE03StabSection(root: HTMLElement): void { const missingHost = el("div"); const planHost = el("div"); const toolbar = el("div", { className: "e03-card__head" }); - const sectionCard = el("div", { className: "e03-card" }); + const sectionCard = card(); const legendHost = el("div"); const canvas = createSectionCanvas((i) => { picked = i; @@ -270,9 +263,9 @@ export function renderE03StabSection(root: HTMLElement): void { if (!view) { canvas.render(null); const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet"); - resultHost.append( - el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }), - ); + const none = card(); + none.append(emptyBox(text)); + resultHost.append(none); return; } const analysis = currentAnalysis(view); @@ -297,13 +290,9 @@ export function renderE03StabSection(root: HTMLElement): void { }); const flags = el("div", { className: "e03-panel__row" }); flags.append( - checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())), - checkbox( - t("E03_Show_Centers"), - toggles.centers, - (v) => ((toggles.centers = v), paintSection()), - ), - checkbox( + check(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())), + check(t("E03_Show_Centers"), toggles.centers, (v) => ((toggles.centers = v), paintSection())), + check( t("E03_Show_Candidates"), toggles.candidates, (v) => ((toggles.candidates = v), paintSection()), @@ -330,15 +319,9 @@ export function renderE03StabSection(root: HTMLElement): void { legendHost.append(buildLegends(draw)); const rule = view.water_rule?.[condition]; if (rule?.mode === "ru") - legendHost.append( - el("p", { - className: "e03-muted", - text: t("E03_Water_Ru").replace("{v}", String(rule.ru)), - attrs: { "data-role": "water-ru" }, - }), - ); + legendHost.append(muted(t("E03_Water_Ru").replace("{v}", String(rule.ru)), "water-ru")); if (view.regions.some((r) => r.material_id === WALL_ID)) - legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); + legendHost.append(muted(t("E03_Walls_Hint"))); const row = currentRow(); if (mode === "prob" && row) { resultHost.append( diff --git a/E03_StabSection/E03_StabSection_UI_Parts.ts b/E03_StabSection/E03_StabSection_UI_Parts.ts new file mode 100644 index 000000000..ec5b6d3da --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Parts.ts @@ -0,0 +1,54 @@ +/* ============================================================================= + * E03_StabSection_UI_Parts.ts + * E03 안 부품 걸이(553-4) — 부품(`ui_template_text` · `_empty` · `_card` · `_checkbox` · `_flow_legend`)을 부르고 + * 촬영 장면 · 시험이 잡는 이름(`e03-muted` · `e03-empty` · `e03-card` · `e03-check` · `e03-legend`)만 얹음. + * 모양은 부품 · 이 파일에 CSS 없음. + * ========================================================================== */ + +import { createCard } from "@ui/ui_template_card"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend"; +import { createText } from "@ui/ui_template_text"; + +/** 보조 글 한 줄(`data-role` 이 있으면 같이) */ +export function muted(text: string, role?: string): HTMLElement { + const node = createText(text, "muted"); + node.classList.add("e03-muted"); + if (role) node.dataset.role = role; + return node; +} + +/** 빈 자리 안내 */ +export function emptyBox(text: string): HTMLElement { + const node = createEmpty({ text, small: true }); + node.classList.add("e03-empty"); + return node; +} + +/** 카드 판 — 속은 부르는 쪽이 채움 */ +export function card(extra = "", role?: string): HTMLDivElement { + const node = createCard({ pad: 12 }); + node.classList.add("e03-card", ...extra.split(" ").filter(Boolean)); + if (role) node.dataset.role = role; + return node; +} + +/** 체크 한 줄 */ +export function check( + label: string, + checked: boolean, + onChange: (v: boolean) => void, +): HTMLElement { + const box = createCheckbox(label, { checked, onChange }); + box.root.classList.add("e03-check"); + return box.root; +} + +/** 범례 — 색은 `var(--e03-…)`(그림 색 · 이 화면 자체 토큰) */ +export function legend(items: LegendItem[], role?: string): HTMLElement { + const node = createLegend(items); + node.classList.add("e03-legend"); + if (role) node.dataset.role = role; + return node; +} diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts index c732c2104..cb38e360c 100644 --- a/E03_StabSection/E03_StabSection_UI_Plan.ts +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -5,6 +5,7 @@ * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { @@ -15,6 +16,7 @@ import { type Point, type StationRow, } from "./logic/E03_StabSection_Logic"; +import { card } from "./E03_StabSection_UI_Parts"; import { svgEl } from "./E03_StabSection_UI_Section"; const H = 96; @@ -26,7 +28,7 @@ export function buildPlan( active: string | null, onSelect: (key: string) => void, ): HTMLElement { - const box = el("div", { className: "e03-plan e03-card" }); + const box = card("e03-plan"); const head = el("div", { className: "e03-card__head" }); head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") })); const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } }); @@ -34,8 +36,12 @@ export function buildPlan( rows.forEach((r) => (counts[verdictOf(r, condition)] += 1)); for (const v of ["ng", "ok", "none"] as const) { if (!counts[v]) continue; - const item = el("span", { className: `e03-verdict e03-verdict--${v}` }); - item.append(`${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`); + const item = createBadge( + `${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`, + v === "ok" ? "success" : v === "ng" ? "danger" : "neutral", + { kind: "lamp" }, + ); + item.classList.add("e03-verdict", `e03-verdict--${v}`); legend.append(item); } head.append(legend); diff --git a/E03_StabSection/E03_StabSection_UI_Prob.css b/E03_StabSection/E03_StabSection_UI_Prob.css index 0a9f45842..01a588331 100644 --- a/E03_StabSection/E03_StabSection_UI_Prob.css +++ b/E03_StabSection/E03_StabSection_UI_Prob.css @@ -11,6 +11,12 @@ width: 6.2em; } +.e03-prob-spread { + display: flex; + gap: 4px; + align-items: center; +} + .e03-prob-run { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/E03_StabSection/E03_StabSection_UI_Prob.ts b/E03_StabSection/E03_StabSection_UI_Prob.ts index d51ce5f5a..045696886 100644 --- a/E03_StabSection/E03_StabSection_UI_Prob.ts +++ b/E03_StabSection/E03_StabSection_UI_Prob.ts @@ -17,7 +17,12 @@ import { el, showToast, } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createKv } from "@ui/ui_template_kv"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { card, emptyBox, muted } from "./E03_StabSection_UI_Parts"; +import { e03Table } from "./E03_StabSection_UI_Table"; import { changedCount, fillCache, @@ -125,10 +130,6 @@ export function createProbPanel(projectId: string, base: string, repaint: () => } function inputTable(setup: ProbSetup): HTMLElement { - const table = el("table", { - className: "e03-table e03-prob-vars", - attrs: { "data-role": "prob-vars" }, - }); const heads = [ "E03_Pb_Use", "E03_Pb_Var", @@ -137,36 +138,36 @@ export function createProbPanel(projectId: string, base: string, repaint: () => "E03_Pb_Spread", "E03_Pb_Trunc", ]; - table.append( - el("thead", { - children: [el("tr", { children: heads.map((k) => el("th", { text: t(k as LocaleKey) })) })], - }), + const rows = probRows(setup); + const list = e03Table( + heads.map((k, i) => ({ label: t(k as LocaleKey), num: i === 2 })), + rows.map(inputCells), + "prob-vars", + "e03-prob-vars", ); - const body = el("tbody"); - for (const row of probRows(setup)) body.append(inputRow(row)); - table.append(body); - return table; + list.rows.forEach((tr, i) => (tr.dataset.key = rows[i].key)); + return list.root; } - function inputRow(row: ProbRow): HTMLElement { + function inputCells(row: ProbRow): HTMLElement[] { const v = cache!.vars[row.key]; - const tr = el("tr", { attrs: { "data-key": row.key } }); - const use = el("input", { attrs: { type: "checkbox", "data-field": "use" } }); - use.checked = v.use; - use.addEventListener("change", () => ((v.use = use.checked), save())); - const dist = el("select", { - className: "ui-input ui-input--sm", - attrs: { "data-field": "dist" }, - }); - for (const d of row.prop === "water" ? ["normal"] : ["normal", "lognormal"]) - dist.append(el("option", { text: t(`E03_Pb_Dist_${d}` as LocaleKey), attrs: { value: d } })); - dist.value = v.dist; + const use = createCheckbox("", { checked: v.use }); + use.input.dataset.field = "use"; + use.input.addEventListener("change", () => ((v.use = use.input.checked), save())); + const dist = createSelectField({ + compact: true, + options: (row.prop === "water" ? ["normal"] : ["normal", "lognormal"]).map((d) => ({ + value: d, + text: t(`E03_Pb_Dist_${d}` as LocaleKey), + })), + value: v.dist, + }).select; + dist.dataset.field = "dist"; dist.addEventListener("change", () => ((v.dist = dist.value as typeof v.dist), save())); const num = (value: number | null, field: string, apply: (n: number | null) => void) => { - const input = el("input", { - className: "ui-input ui-input--sm", - attrs: { type: "number", step: "0.01", min: "0", "data-field": field }, - }); + const input = createInputField({ type: "number", compact: true, min: 0 }).input; + input.step = "0.01"; + input.dataset.field = field; input.value = value === null ? "" : String(value); input.addEventListener("input", () => { const n = Number(input.value); @@ -181,20 +182,18 @@ export function createProbPanel(projectId: string, base: string, repaint: () => row.prop === "water" || row.prop === "ru" ? t(`E03_Pb_Prop_${row.prop}` as LocaleKey) : `${row.name} ${t(`E03_Pb_Prop_${row.prop}` as LocaleKey)}`; - tr.append( - el("td", { children: [use] }), - el("td", { text: label }), - el("td", { className: "is-num", text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }), - el("td", { children: [dist] }), - el("td", { - children: [ - num(v.value, "value", (n) => (v.value = n ?? 0)), - el("span", { className: "e03-muted", text: ` ${unit}` }), - ], - }), - el("td", { children: [num(v.trunc, "trunc", (n) => (v.trunc = n))] }), - ); - return tr; + const spread = el("div", { + className: "e03-prob-spread", + children: [num(v.value, "value", (n) => (v.value = n ?? 0)), createText(` ${unit}`, "muted")], + }); + return [ + use.root, + el("span", { text: label }), + el("span", { text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }), + dist, + spread, + num(v.trunc, "trunc", (n) => (v.trunc = n)), + ]; } function runFields(setup: ProbSetup, slot: string, editable: boolean): HTMLElement { @@ -304,12 +303,13 @@ export function createProbPanel(projectId: string, base: string, repaint: () => ["E03_Pb_Valid", `${s.n_valid} / ${s.n}`], ["E03_Pb_Time", `${fmt(rec.result.elapsed_s.total, 1)} s`], ]; - const dl = el("dl", { className: "e03-kpi", attrs: { "data-role": "prob-summary" } }); - for (const [k, v] of rows) - dl.append(el("dt", { text: t(k) }), el("dd", { text: v, attrs: { "data-k": k } })); + const dl = createKv(rows.map(([k, v]) => ({ label: t(k), value: v }))); + dl.classList.add("e03-kpi"); + dl.dataset.role = "prob-summary"; + dl.querySelectorAll("dd").forEach((dd, i) => dd.setAttribute("data-k", rows[i][0])); const r = rec.result; const note = `${t(`E03_Pb_Mode_${r.mode}` as LocaleKey)} · ${methodName(r.method)} · ${r.sampling.toUpperCase()} ${r.n} · seed ${r.seed} · ${rec.computed_at}`; - return el("div", { children: [dl, el("p", { className: "e03-muted", text: note })] }); + return el("div", { children: [dl, muted(note)] }); } function sources(setup: ProbSetup, changed: number): HTMLElement { @@ -318,18 +318,17 @@ export function createProbPanel(projectId: string, base: string, repaint: () => ? t("E03_Pb_UserInput").replace("{n}", String(changed)) : t("E03_Pb_Unconfirmed"); d.append(el("summary", { text: tag })); - for (const [k, v] of Object.entries(setup.defaults.sources)) - d.append(el("p", { className: "e03-muted", text: `${k} — ${v}` })); + for (const [k, v] of Object.entries(setup.defaults.sources)) d.append(muted(`${k} — ${v}`)); return d; } function render(v: ProbView): HTMLElement { - const host = el("div", { className: "e03-card e03-prob", attrs: { "data-role": "prob" } }); + const host = card("e03-prob", "prob"); const head = (key: LocaleKey): HTMLElement => el("h3", { className: "e03-card__title", text: t(key) }); host.append(head("E03_Tab_Prob")); if (!v.row.representative?.on) { - host.append(el("p", { className: "e03-empty", text: t("E03_Pb_RepOnly") })); + host.append(emptyBox(t("E03_Pb_RepOnly"))); return host; } const side = v.side ?? v.row.sides[0]?.side; @@ -339,30 +338,20 @@ export function createProbPanel(projectId: string, base: string, repaint: () => if (got === undefined) queueMicrotask(() => void load(slot, v.row.key, side, v.condition)); if (typeof got !== "object") { const text = typeof got === "string" && got !== "loading" ? got : t("E03_Loading"); - host.append(el("p", { className: "e03-empty", text })); + host.append(emptyBox(text)); return host; } cache = fillCache(got, cache); const sideText = `${t(side === "left" ? "E03_Left" : "E03_Right")} · ${t(got.role === "cut" ? "E03_Cut" : "E03_Fill")}`; host.append( - el("p", { - className: "e03-muted", - text: `${sideText} · ${t(`E03_Cond_${v.condition}` as LocaleKey)} · ${t("E03_Pb_MeanNote")}`, - }), + muted(`${sideText} · ${t(`E03_Cond_${v.condition}` as LocaleKey)} · ${t("E03_Pb_MeanNote")}`), ); host.append( inputTable(got), runFields(got, slot, v.editable), sources(got, changedCount(got, cache)), ); - if (busy) - host.append( - el("p", { - className: "e03-muted", - text: `${busy.percent}% · ${busy.message}`, - attrs: { "data-role": "prob-progress" }, - }), - ); + if (busy) host.append(muted(`${busy.percent}% · ${busy.message}`, "prob-progress")); const rec = records.get(slot); if (!rec) return host; const res = rec.result; diff --git a/E03_StabSection/E03_StabSection_UI_ProbChart.ts b/E03_StabSection/E03_StabSection_UI_ProbChart.ts index 08bf0de62..75eeda5a2 100644 --- a/E03_StabSection/E03_StabSection_UI_ProbChart.ts +++ b/E03_StabSection/E03_StabSection_UI_ProbChart.ts @@ -8,6 +8,7 @@ import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { axisTicks, type ProbCurve, type ProbResult } from "./logic/E03_StabSection_Prob"; import { pointsText, type Point } from "./logic/E03_StabSection_Logic"; +import { legend } from "./E03_StabSection_UI_Parts"; import { svgEl } from "./E03_StabSection_UI_Section"; const W = 380; @@ -128,23 +129,9 @@ export function cdfChart(res: ProbResult, required: number | null): HTMLElement } function legendOf(curves: ProbCurve[]): HTMLElement { - const legend = el("div", { className: "e03-legend" }); - curves.forEach((c, i) => { - const item = el("span", { className: "e03-legend__item" }); - const sw = svgEl("svg", { width: 18, height: 8 }); - sw.append( - svgEl("line", { - x1: 0, - x2: 18, - y1: 4, - y2: 4, - class: `e03-prob-line e03-prob-line--${i % 8}`, - }), - ); - item.append(sw as unknown as HTMLElement, c.name); - legend.append(item); - }); - return legend; + return legend( + curves.map((c, i) => ({ label: c.name, color: `var(--e03-mat-${i % 8})`, kind: "line" })), + ); } /** 민감도 — 가로 = 범위 %(0 = 아래 끝 · 100 = 위 끝) · 나머지 변수 평균 · 임계면 고정 */ diff --git a/E03_StabSection/E03_StabSection_UI_Reinforce.ts b/E03_StabSection/E03_StabSection_UI_Reinforce.ts index 58216e0d5..f6cb03fb8 100644 --- a/E03_StabSection/E03_StabSection_UI_Reinforce.ts +++ b/E03_StabSection/E03_StabSection_UI_Reinforce.ts @@ -10,6 +10,8 @@ import { createButton, createInputField, createSelectField, el } from "@ui/ui_te import { t, type LocaleKey } from "@ui/ui_template_locale"; import { createSectionCanvas } from "./E03_StabSection_UI_Section"; import { verdictChip } from "./E03_StabSection_UI_Result"; +import { card, muted } from "./E03_StabSection_UI_Parts"; +import { e03Table } from "./E03_StabSection_UI_Table"; import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; export interface WhatIfRecord { @@ -89,39 +91,33 @@ function num(input: HTMLInputElement): number | null { /** 전후 안전율 · 판정 표 — 비탈 가정 · 네일 · 앵커 가정 같이 씀 */ export function slopeTable(rec: Pick): HTMLElement { - const table = el("table", { className: "e03-table", attrs: { "data-role": "whatif-table" } }); const head = ["E03_Condition", "E03_Rf_Before", "E03_Rf_After", "E03_Required"] as LocaleKey[]; - table.append( - el("thead", { - children: [ - el("tr", { - children: [ - ...head.map((k, i) => el("th", { className: i ? "is-num" : "", text: t(k) })), - el("th", { text: t("E03_Rf_VerdictBefore") }), - el("th", { text: t("E03_Rf_VerdictAfter") }), - ], - }), - ], + const conds = CONDS.filter((cond) => rec.after[cond]); + const list = e03Table( + [ + ...head.map((k, i) => ({ label: t(k), num: i > 0 })), + { label: t("E03_Rf_VerdictBefore") }, + { label: t("E03_Rf_VerdictAfter") }, + ], + conds.map((cond) => { + const a = rec.after[cond]!; + const b = rec.before[cond]; + return [ + t(`E03_Cond_${cond}` as LocaleKey), + fmt(b?.fs, 3), + fmt(a.fs, 3), + fmt(a.required), + verdictChip(b?.ok ?? null), + verdictChip(a.ok), + ]; }), + "whatif-table", ); - const body = el("tbody"); - for (const cond of CONDS) { - const a = rec.after[cond]; - if (!a) continue; - const b = rec.before[cond]; - const tr = el("tr", { attrs: { "data-cond": cond } }); - tr.append( - el("td", { text: t(`E03_Cond_${cond}` as LocaleKey) }), - el("td", { className: "is-num", text: fmt(b?.fs, 3) }), - el("td", { className: "is-num", text: fmt(a.fs, 3), attrs: { "data-role": "fs-after" } }), - el("td", { className: "is-num", text: fmt(a.required) }), - el("td", { children: [verdictChip(b?.ok ?? null)] }), - el("td", { children: [verdictChip(a.ok)] }), - ); - body.append(tr); - } - table.append(body); - return table; + list.rows.forEach((tr, i) => { + tr.dataset.cond = conds[i]; + tr.cells[2].dataset.role = "fs-after"; + }); + return list.root; } function assumeText(rec: WhatIfRecord): string { @@ -140,52 +136,39 @@ function wallTable(rec: WallRecord): HTMLElement { for (const wall of rec.walls) { for (const c of wall.cases) { box.append( - el("p", { - className: "e03-muted", - text: - `${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` + + muted( + `${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` + `H ${fmt(c.geometry.H)} m · B ${fmt(c.geometry.B)} m · Ka ${fmt(c.coefficients.ka, 3)}` + (c.coefficients.kae ? ` · KAE ${fmt(c.coefficients.kae, 3)}` : "") + ` · e ${fmt(c.base.e)} m · q_max ${fmt(c.base.q_max, 1)} kPa`, - }), + ), ); - const table = el("table", { className: "e03-table" }); - table.append( - el("thead", { - children: [ - el("tr", { - children: [ - el("th", { text: t("E03_Rf_Check") }), - el("th", { className: "is-num", text: t("E03_Rf_Fs") }), - el("th", { className: "is-num", text: t("E03_Required") }), - el("th", { text: t("E03_Verdict") }), - ], - }), - ], - }), + const checks = e03Table( + [ + { label: t("E03_Rf_Check") }, + { label: t("E03_Rf_Fs"), num: true }, + { label: t("E03_Required"), num: true }, + { label: t("E03_Verdict") }, + ], + CHECKS.map((k) => [ + t(`E03_Rf_${k}` as LocaleKey), + fmt(c.fs[k]), + fmt(c.required[k], 1), + verdictChip(c.ok[k]), + ]), ); - const body = el("tbody"); - for (const k of CHECKS) - body.append( - el("tr", { - attrs: { "data-check": k, "data-case": c.case }, - children: [ - el("td", { text: t(`E03_Rf_${k}` as LocaleKey) }), - el("td", { className: "is-num", text: fmt(c.fs[k]) }), - el("td", { className: "is-num", text: fmt(c.required[k], 1) }), - el("td", { children: [verdictChip(c.ok[k])] }), - ], - }), - ); - table.append(body); - box.append(table); + checks.rows.forEach((tr, i) => { + tr.dataset.check = CHECKS[i]; + tr.dataset.case = c.case; + }); + box.append(checks.root); } } return box; } export function buildReinforce(o: ReinforceOptions): HTMLElement { - const host = el("div", { className: "e03-card", attrs: { "data-role": "reinforce" } }); + const host = card("", "reinforce"); const sides = o.row.sides; const sideField = createSelectField({ label: t("E03_Side"), @@ -247,21 +230,14 @@ export function buildReinforce(o: ReinforceOptions): HTMLElement { buttons.append(find, run); host.append( el("h3", { className: "e03-card__title", text: t("E03_Rf_Title") }), - el("p", { className: "e03-muted", text: t("E03_Rf_Hint") }), - el("p", { - className: "e03-muted", - text: t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey)), - }), + muted(t("E03_Rf_Hint")), + muted(t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey))), fields, buttons, ); if (o.slope) { host.append( - el("p", { - className: "e03-muted", - text: assumeText(o.slope), - attrs: { "data-role": "assume" }, - }), + muted(assumeText(o.slope), "assume"), slopeTable(o.slope), el("h4", { className: "e03-card__title", text: t("E03_Rf_AfterSection") }), ); diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts index 70e35e2a4..c165f7a87 100644 --- a/E03_StabSection/E03_StabSection_UI_Result.ts +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -5,7 +5,9 @@ * 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic. * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; import { el } from "@ui/ui_template_elements"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { forceArrows, @@ -18,6 +20,8 @@ import { type SectionView, } from "./logic/E03_StabSection_Logic"; import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; +import { card, emptyBox, legend, muted } from "./E03_StabSection_UI_Parts"; +import { e03Table } from "./E03_StabSection_UI_Table"; const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); @@ -26,57 +30,59 @@ const METHODS = ["ordinary", "bishop", "janbu", "spencer"] as const; export function verdictChip(ok: boolean | null): HTMLElement { const v = ok === null ? "none" : ok ? "ok" : "ng"; - return el("span", { - className: `e03-verdict e03-verdict--${v}`, - text: t(`E03_Verdict_${v}` as LocaleKey), - }); + const chip = createBadge( + t(`E03_Verdict_${v}` as LocaleKey), + v === "ok" ? "success" : v === "ng" ? "danger" : "neutral", + { kind: "lamp" }, + ); + chip.classList.add("e03-verdict", `e03-verdict--${v}`); + return chip; } function kpi(analysis: Analysis, walls: number): HTMLElement { const c = analysis.critical; - const dl = el("dl", { className: "e03-kpi" }); - const row = (label: string, value: HTMLElement | string, big = false): void => { - const dd = el("dd", { className: big ? "is-big" : "" }); - dd.append(value); - dl.append(el("dt", { text: label }), dd); - }; - row(t("E03_Fs"), fmt(c?.fs, 3), true); - row(t("E03_Verdict"), verdictChip(analysis.ok)); - row(t("E03_Required"), fmt(analysis.required)); - row( - t("E03_Method"), - `${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`, - ); - row( - t("E03_Converged"), - c - ? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}` - : "-", - ); - row(t("E03_Height"), `${fmt(analysis.height_m)} m`); + const rows: KvRow[] = [ + { label: t("E03_Fs"), value: fmt(c?.fs, 3), big: true }, + { label: t("E03_Verdict"), value: verdictChip(analysis.ok) }, + { label: t("E03_Required"), value: fmt(analysis.required) }, + { + label: t("E03_Method"), + value: `${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`, + }, + { + label: t("E03_Converged"), + value: c + ? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}` + : "-", + }, + { label: t("E03_Height"), value: `${fmt(analysis.height_m)} m` }, + ]; if (walls) - row(t("E03_Walls"), el("span", { text: String(walls), attrs: { "data-role": "walls" } })); + rows.push({ + label: t("E03_Walls"), + value: el("span", { text: String(walls), attrs: { "data-role": "walls" } }), + }); if (c?.center) { - row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`); - row(t("E03_Radius"), `${fmt(c.radius)} m`); + rows.push({ label: t("E03_Center"), value: `${fmt(c.center[0])}, ${fmt(c.center[1])}` }); + rows.push({ label: t("E03_Radius"), value: `${fmt(c.radius)} m` }); } + const dl = createKv(rows); + dl.classList.add("e03-kpi"); return dl; } function methodTable(analysis: Analysis): HTMLElement { - const table = el("table", { className: "e03-table", attrs: { "data-role": "methods" } }); - const head = el("tr"); - const body = el("tr"); - for (const m of METHODS) { - head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) })); - const v = analysis.min_by_method[m]; - const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m); - body.append( - el("td", { className: "is-num", text: unsolved ? t("E03_NoSolution") : fmt(v, 3) }), - ); - } - table.append(el("thead", { children: [head] }), el("tbody", { children: [body] })); - return table; + return e03Table( + METHODS.map((m) => ({ label: t(`E03_Method_${m}` as LocaleKey), num: true })), + [ + METHODS.map((m) => { + const v = analysis.min_by_method[m]; + const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m); + return unsolved ? t("E03_NoSolution") : fmt(v, 3); + }), + ], + "methods", + ).root; } function sliceTable( @@ -85,7 +91,6 @@ function sliceTable( onPick: (i: number | null) => void, ): HTMLElement { const wrap = el("div", { className: "e03-slices" }); - const table = el("table", { className: "e03-table", attrs: { "data-role": "slice-table" } }); const cols: [LocaleKey, (s: Analysis["slices"][number]) => string][] = [ ["E03_Col_No", (s) => String(s.i)], ["E03_Col_Width", (s) => fmt(s.width)], @@ -98,27 +103,20 @@ function sliceTable( ["E03_Col_C", (s) => fmt(s.c, 0)], ["E03_Col_Phi", (s) => fmt(s.phi_deg, 0)], ]; - table.append( - el("thead", { - children: [ - el("tr", { children: cols.map(([k]) => el("th", { className: "is-num", text: t(k) })) }), - ], - }), + const slices = e03Table( + cols.map(([k]) => ({ label: t(k), num: true })), + analysis.slices.map((s) => cols.map(([, f]) => f(s))), + "slice-table", ); - const body = el("tbody"); - analysis.slices.forEach((s, i) => { - const tr = el("tr", { - className: picked === i ? "is-picked" : "", - attrs: { "data-i": String(i) }, - }); - for (const [, f] of cols) tr.append(el("td", { className: "is-num", text: f(s) })); + slices.rows.forEach((tr, i) => { + tr.dataset.i = String(i); + tr.classList.toggle("is-picked", picked === i); + tr.classList.toggle("is-active", picked === i); tr.addEventListener("click", () => onPick(picked === i ? null : i)); - body.append(tr); }); - table.append(body); - wrap.append(table); + wrap.append(slices.root); if (picked !== null) - queueMicrotask(() => body.children[picked]?.scrollIntoView({ block: "nearest" })); + queueMicrotask(() => slices.rows[picked]?.scrollIntoView({ block: "nearest" })); return wrap; } @@ -127,7 +125,7 @@ function freeBody(view: SectionView, analysis: Analysis, picked: number | null): const box = el("div", { className: "e03-fbd" }); const slice = picked !== null ? analysis.slices[picked] : null; if (!slice || !analysis.critical) { - box.append(el("p", { className: "e03-muted", text: t("E03_PickSlice") })); + box.append(muted(t("E03_PickSlice"))); return box; } const W = 380; @@ -198,10 +196,9 @@ function freeBody(view: SectionView, analysis: Analysis, picked: number | null): } box.append(svg); box.append( - el("p", { - className: "e03-muted", - text: `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`, - }), + muted( + `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`, + ), ); return box; } @@ -261,19 +258,13 @@ function stressGraph(analysis: Analysis, picked: number | null): HTMLElement { svg.append(hit); }); box.append(svg); - const legend = el("div", { className: "e03-legend" }); - for (const [key, label] of [ - ["sigma", "E03_Graph_Sigma"], - ["u", "E03_Graph_U"], - ["tau", "E03_Graph_Tau"], - ] as const) { - const item = el("span", { className: "e03-legend__item" }); - const sw = svgEl("svg", { width: 18, height: 8 }); - sw.append(svgEl("line", { x1: 0, x2: 18, y1: 4, y2: 4, class: `e03-line e03-line--${key}` })); - item.append(sw as unknown as HTMLElement, t(label as LocaleKey)); - legend.append(item); - } - box.append(legend); + box.append( + legend([ + { label: t("E03_Graph_Sigma"), color: "var(--e03-mat-0)", kind: "line" }, + { label: t("E03_Graph_U"), color: "var(--e03-water)", kind: "dash" }, + { label: t("E03_Graph_Tau"), color: "var(--e03-mat-1)", kind: "line" }, + ]), + ); return box; } @@ -283,9 +274,9 @@ export function buildResult( picked: number | null, onPick: (i: number | null) => void, ): HTMLElement { - const root = el("div", { className: "e03-result e03-card" }); + const root = card("e03-result"); if (!analysis || !analysis.critical) { - root.append(el("p", { className: "e03-empty", text: t("E03_NoSurface") })); + root.append(emptyBox(t("E03_NoSurface"))); return root; } const head = (key: LocaleKey): HTMLElement => diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts index ff43d86e6..a1bc668e5 100644 --- a/E03_StabSection/E03_StabSection_UI_Section.ts +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -11,6 +11,7 @@ import "./E03_StabSection_UI_Walls.css"; import "./E03_StabSection_UI_Support.css"; import { t } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; +import { legend, muted } from "./E03_StabSection_UI_Parts"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { bandIndex, @@ -74,34 +75,34 @@ export function buildLegends(draw: SectionDraw): HTMLElement { const box = el("div", { className: "e03-legend" }); const required = draw.analysis?.required ?? null; if (draw.toggles.map && draw.analysis?.grid) { - box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Map") })); - for (const band of bandLegend(required)) { - const text = - band.from === null - ? `< ${band.to}` - : band.to === null - ? `≥ ${band.from}` - : `${band.from}–${band.to}`; - const item = el("span", { - className: "e03-legend__item", - attrs: { "data-band": String(band.index) }, - }); - item.append(el("span", { className: `e03-legend__swatch e03-band-${band.index}` }), text); - box.append(item); - } + box.append(muted(t("E03_Legend_Map"))); + const bands = bandLegend(required); + const row = legend( + bands.map((band) => ({ + label: + band.from === null + ? `< ${band.to}` + : band.to === null + ? `≥ ${band.from}` + : `${band.from}–${band.to}`, + color: `var(--e03-band-${band.index})`, + })), + ); + row.querySelectorAll(".ui-legend__item").forEach((item, k) => { + item.setAttribute("data-band", String(bands[k].index)); + }); + box.append(row); } const used = new Set(draw.view.regions.map((r) => r.material_id)); - box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Layers") })); - for (const m of draw.view.materials.filter((x) => used.has(x.id))) { - const item = el("span", { className: "e03-legend__item" }); - item.append( - el("span", { - className: `e03-legend__swatch ${m.id === WALL_ID ? "e03-wall-swatch" : materialClass(draw.view, m.id)}`, - }), - m.name, - ); - box.append(item); - } + box.append(muted(t("E03_Legend_Layers"))); + const layers = draw.view.materials.filter((x) => used.has(x.id)); + const row = legend( + layers.map((m) => ({ label: m.name, color: `var(--${materialClass(draw.view, m.id)})` })), + ); + row.querySelectorAll(".ui-legend__mark").forEach((mark, k) => { + if (layers[k].id === WALL_ID) mark.classList.add("e03-wall-swatch"); + }); + box.append(row); return box; } diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index c7bbb4419..3c3d76134 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -94,15 +94,13 @@ min-height: 0; } +/* 카드 · 보조 글 · 빈 자리 · 체크 · 표 · 범례 · 값 목록 · 모달은 부품 — 여기는 자리만 */ .e03-card { display: flex; flex-direction: column; gap: var(--spacing-8); min-width: 0; min-height: 0; - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); } .e03-card__head { @@ -121,17 +119,11 @@ } .e03-muted { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); + white-space: pre-line; } -.e03-empty { +.e03-card > .e03-empty { margin: auto; - color: var(--color-text-secondary); - font-size: var(--text-body); - text-align: center; - white-space: pre-line; } /* ---- 좌측 측점 목록 ---- */ @@ -155,16 +147,6 @@ min-width: 0; } -.e03-check { - display: inline-flex; - align-items: center; - gap: var(--spacing-4); - color: var(--color-text-body); - font-size: var(--text-body-sm); - cursor: pointer; - user-select: none; -} - .e03-list { flex: 1 1 auto; min-height: 0; @@ -172,89 +154,37 @@ border-top: 1px solid var(--color-border); } -.e03-list table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-body-sm); - color: var(--color-text-body); +.e03-list .ui-table-wrap, +.e03-slices .ui-table-wrap { + overflow: visible; + border: 0; + border-radius: 0; + background: none; } -.e03-list th { +.e03-list th, +.e03-slices th { position: sticky; top: 0; z-index: 1; - padding: var(--spacing-4); background: var(--color-surface-raised); - border-bottom: 1px solid var(--color-border); - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - text-align: left; - white-space: nowrap; } -.e03-list td { - padding: 3px var(--spacing-4); - border-bottom: 1px solid var(--color-border); - white-space: nowrap; -} - -.e03-list td.is-num, -.e03-list th.is-num, -.e03-table td.is-num, -.e03-table th.is-num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.e03-list tbody tr { +.e03-list tbody tr, +.e03-slices tbody tr { cursor: pointer; } -.e03-list tbody tr:hover { - background: var(--color-surface-sunken); +.e03-list .ui-table th, +.e03-list .ui-table td, +.e03-slices .ui-table th, +.e03-slices .ui-table td { + padding: 0 4px; + white-space: nowrap; } -.e03-list tbody tr.is-active { - background: var(--color-surface-sunken); - outline: 2px solid var(--color-accent); - outline-offset: -2px; - font-weight: var(--font-weight-medium); -} - -.e03-verdict { - display: inline-flex; - align-items: center; - gap: 4px; - font-weight: var(--font-weight-medium); - color: var(--color-text); -} - -.e03-verdict::before { - content: ""; - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--e03-none); -} - -.e03-verdict--ok::before { - background: var(--e03-ok); -} - -.e03-verdict--ng::before { - background: var(--e03-ng); - border-radius: 2px; - transform: rotate(45deg); -} - -.e03-star { - border: none; - background: none; - padding: 0 2px; - color: var(--color-text-muted); - font-size: 15px; - line-height: 1; - cursor: pointer; +.e03-check { + font-size: var(--text-body-sm); } .e03-star.is-on { @@ -265,10 +195,6 @@ text-decoration: underline; } -.e03-star:disabled { - cursor: default; -} - /* ---- 평면 띠 ---- */ .e03-plan svg { display: block; @@ -309,22 +235,6 @@ flex-wrap: wrap; gap: var(--spacing-4) var(--spacing-12); align-items: center; - color: var(--color-text-body); - font-size: var(--text-caption, 12px); -} - -.e03-legend__item { - display: inline-flex; - align-items: center; - gap: 4px; - white-space: nowrap; -} - -.e03-legend__swatch { - width: 14px; - height: 10px; - border-radius: 2px; - border: 1px solid var(--color-border); } /* ---- 횡단 ---- */ @@ -458,63 +368,6 @@ overflow: auto; } -.e03-kpi { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-4) var(--spacing-12); - margin: 0; - font-size: var(--text-body-sm); -} - -.e03-kpi dt { - color: var(--color-text-secondary); -} - -.e03-kpi dd { - margin: 0; - color: var(--color-text); - font-variant-numeric: tabular-nums; -} - -.e03-kpi dd.is-big { - font-size: 22px; - font-weight: 700; -} - -.e03-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-body-sm); - color: var(--color-text-body); -} - -.e03-table th { - position: sticky; - top: 0; - background: var(--color-surface-raised); - padding: 2px var(--spacing-4); - border-bottom: 1px solid var(--color-border); - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.e03-table td { - padding: 2px var(--spacing-4); - border-bottom: 1px solid var(--color-border); - white-space: nowrap; -} - -.e03-table tbody tr { - cursor: pointer; -} - -.e03-table tbody tr.is-picked { - background: var(--color-surface-sunken); - outline: 2px solid var(--color-accent); - outline-offset: -2px; -} - .e03-slices { max-height: 220px; overflow: auto; @@ -577,26 +430,6 @@ } /* ---- 3D 보기 ---- */ -.e03-view3d { - position: fixed; - inset: 5vh 5vw; - z-index: 800; - display: flex; - flex-direction: column; - border-radius: var(--radius-sm); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); - overflow: hidden; -} - -.e03-view3d__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); -} - .e03-view3d__canvas { flex: 1 1 auto; min-height: 0; @@ -612,79 +445,60 @@ /* ---- 색 칸(지층 · 등안전율 띠) ---- */ .e03-mat-0 { fill: var(--e03-mat-0); - background: var(--e03-mat-0); } .e03-mat-1 { fill: var(--e03-mat-1); - background: var(--e03-mat-1); } .e03-mat-2 { fill: var(--e03-mat-2); - background: var(--e03-mat-2); } .e03-mat-3 { fill: var(--e03-mat-3); - background: var(--e03-mat-3); } .e03-mat-4 { fill: var(--e03-mat-4); - background: var(--e03-mat-4); } .e03-mat-5 { fill: var(--e03-mat-5); - background: var(--e03-mat-5); } .e03-mat-6 { fill: var(--e03-mat-6); - background: var(--e03-mat-6); } .e03-mat-7 { fill: var(--e03-mat-7); - background: var(--e03-mat-7); } .e03-band-0 { fill: var(--e03-band-0); - background: var(--e03-band-0); } .e03-band-1 { fill: var(--e03-band-1); - background: var(--e03-band-1); } .e03-band-2 { fill: var(--e03-band-2); - background: var(--e03-band-2); } .e03-band-3 { fill: var(--e03-band-3); - background: var(--e03-band-3); } .e03-band-4 { fill: var(--e03-band-4); - background: var(--e03-band-4); } .e03-band-5 { fill: var(--e03-band-5); - background: var(--e03-band-5); } .e03-band-6 { fill: var(--e03-band-6); - background: var(--e03-band-6); -} - -.e03-swatch-none { - background: var(--e03-none); } diff --git a/E03_StabSection/E03_StabSection_UI_Support.ts b/E03_StabSection/E03_StabSection_UI_Support.ts index 07c8f829b..1adb82b57 100644 --- a/E03_StabSection/E03_StabSection_UI_Support.ts +++ b/E03_StabSection/E03_StabSection_UI_Support.ts @@ -10,6 +10,8 @@ import { createButton, createInputField, createSelectField, el } from "@ui/ui_te import { t, type LocaleKey } from "@ui/ui_template_locale"; import { slopeTable } from "./E03_StabSection_UI_Reinforce"; import { createSectionCanvas } from "./E03_StabSection_UI_Section"; +import { card, muted } from "./E03_StabSection_UI_Parts"; +import { e03Table } from "./E03_StabSection_UI_Table"; import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; import { layoutSupports, @@ -100,50 +102,37 @@ function assumeText(rec: SupportRecord): string { function forceTable(rec: SupportRecord, condition: Condition): HTMLElement { const forces = rec.support_forces?.[condition] ?? null; - const table = el("table", { className: "e03-table", attrs: { "data-role": "support-table" } }); const head = ["E03_Sp_No", "E03_Sp_Type", "E03_Sp_Head", "E03_Sp_Length", "E03_Sp_Force"]; - table.append( - el("thead", { - children: [ - el("tr", { - children: [ - ...head.map((k, i) => - el("th", { className: i > 1 ? "is-num" : "", text: t(k as LocaleKey) }), - ), - el("th", { text: t("E03_Sp_Mode") }), - ], - }), - ], + const found = rec.assume.supports.map((s, i) => ({ s, f: forces?.find((v) => v.index === i) })); + const list = e03Table( + [ + ...head.map((k, i) => ({ label: t(k as LocaleKey), num: i > 1 })), + { label: t("E03_Sp_Mode") }, + ], + found.map(({ s, f }, i) => { + const mode = f?.mode ?? "pending"; + const swatch = el("span", { + className: `e03-support e03-support--${mode}`, + children: [el("span", { className: "e03-support-swatch" })], + }); + const cell = document.createDocumentFragment(); + cell.append(swatch, el("span", { text: t(`E03_Sp_Mode_${mode}` as LocaleKey) })); + return [ + String(i + 1), + t(`E03_Sp_${s.type}` as LocaleKey), + `${fmt(s.x)} / ${fmt(s.z)}`, + fmt(s.length_m, 1), + fmt(f?.mobilized_kN_per_m, 1), + cell, + ]; }), + "support-table", ); - const body = el("tbody"); - rec.assume.supports.forEach((s, i) => { - const f = forces?.find((v) => v.index === i); - const mode = f?.mode ?? "pending"; - body.append( - el("tr", { - attrs: { "data-i": String(i), "data-mode": mode }, - children: [ - el("td", { text: String(i + 1) }), - el("td", { text: t(`E03_Sp_${s.type}` as LocaleKey) }), - el("td", { className: "is-num", text: `${fmt(s.x)} / ${fmt(s.z)}` }), - el("td", { className: "is-num", text: fmt(s.length_m, 1) }), - el("td", { className: "is-num", text: fmt(f?.mobilized_kN_per_m, 1) }), - el("td", { - children: [ - el("span", { - className: `e03-support e03-support--${mode}`, - children: [el("span", { className: "e03-support-swatch" })], - }), - el("span", { text: t(`E03_Sp_Mode_${mode}` as LocaleKey) }), - ], - }), - ], - }), - ); + list.rows.forEach((tr, i) => { + tr.dataset.i = String(i); + tr.dataset.mode = found[i].f?.mode ?? "pending"; }); - table.append(body); - return table; + return list.root; } function legend(): HTMLElement { @@ -160,7 +149,7 @@ function legend(): HTMLElement { } export function buildSupport(o: SupportOptions): HTMLElement { - const host = el("div", { className: "e03-card", attrs: { "data-role": "support" } }); + const host = card("", "support"); const sides = o.row.sides; const sideField = createSelectField({ label: t("E03_Side"), @@ -246,32 +235,15 @@ export function buildSupport(o: SupportOptions): HTMLElement { buttons.append(find, run); host.append( el("h3", { className: "e03-card__title", text: t("E03_Sp_Title") }), - el("p", { className: "e03-muted", text: t("E03_Sp_Hint") }), - el("p", { - className: "e03-muted", - text: t("E03_Sp_Source"), - attrs: { "data-role": "support-source" }, - }), + muted(t("E03_Sp_Hint")), + muted(t("E03_Sp_Source"), "support-source"), fields, buttons, ); const rec = o.record; if (!rec) return host; - host.append( - el("p", { - className: "e03-muted", - text: assumeText(rec), - attrs: { "data-role": "support-assume" }, - }), - ); - if (rec.supports_pending) - host.append( - el("p", { - className: "e03-muted", - text: t("E03_Sp_Pending"), - attrs: { "data-role": "support-pending" }, - }), - ); + host.append(muted(assumeText(rec), "support-assume")); + if (rec.supports_pending) host.append(muted(t("E03_Sp_Pending"), "support-pending")); host.append(slopeTable(rec), forceTable(rec, o.condition), legend()); const canvas = createSectionCanvas(() => undefined); canvas.root.style.height = "260px"; diff --git a/E03_StabSection/E03_StabSection_UI_Table.ts b/E03_StabSection/E03_StabSection_UI_Table.ts new file mode 100644 index 000000000..fce79c247 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Table.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * E03_StabSection_UI_Table.ts + * E03 표 하나(553-4) — 표 부품(`ui_template_table` · 데이터표 줄 28)을 부르고 촬영 장면 · 시험이 잡는 + * 걸이(`table.e03-table[data-role]` · `tr`)만 얹음. 모양은 부품 · 여기는 CSS 없음. + * ========================================================================== */ + +import { createTable, type Cell } from "@ui/ui_template_table"; + +export interface E03Column { + label: string; + /** 수 칸 — 오른쪽 맞춤 */ + num?: boolean; + /** 마우스 글 */ + title?: string; +} + +export interface E03TableHandle { + /** 표 감싸개(`.ui-table-wrap`) */ + root: HTMLElement; + table: HTMLTableElement; + rows: HTMLTableRowElement[]; +} + +export function e03Table( + columns: E03Column[], + rows: Cell[][], + role?: string, + extraClass?: string, +): E03TableHandle { + const root = createTable( + columns.map((c) => ({ label: c.label, ...(c.num ? { align: "end" as const } : {}) })), + rows, + { density: "data" }, + ); + const table = root.querySelector("table") as HTMLTableElement; + table.classList.add("e03-table"); + if (extraClass) table.classList.add(...extraClass.split(" ")); + if (role) table.dataset.role = role; + columns.forEach((c, i) => { + if (c.title) table.tHead?.rows[0].cells[i].setAttribute("title", c.title); + }); + return { root, table, rows: [...table.tBodies[0].rows] }; +} diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts index 4042d0d22..5ea6b5006 100644 --- a/E03_StabSection/E03_StabSection_UI_View3D.ts +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -7,8 +7,10 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -import { createButton, el } from "@ui/ui_template_elements"; +import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { legend, muted } from "./E03_StabSection_UI_Parts"; import { bandIndex, RATIO_BREAKS, @@ -48,35 +50,34 @@ export async function openView3d( condition: Condition, conditionLabel: string, ): Promise { - const shell = el("div", { className: "e03-view3d", attrs: { "data-role": "view3d" } }); - const head = el("div", { className: "e03-view3d__head" }); - const legend = el("div", { className: "e03-legend" }); - legend.append( - el("span", { className: "e03-muted", text: `${conditionLabel} · ${t("E03_View3d_Legend")}` }), - ); const edges = [null, ...RATIO_BREAKS, null]; - for (let i = 0; i <= RATIO_BREAKS.length; i += 1) { - const item = el("span", { className: "e03-legend__item" }); - const text = + const items = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({ + label: edges[i] === null ? `< ${edges[i + 1]}` : edges[i + 1] === null ? `≥ ${edges[i]}` - : `${edges[i]}–${edges[i + 1]}`; - item.append(el("span", { className: `e03-legend__swatch e03-band-${i}` }), text); - legend.append(item); - } - const noneItem = el("span", { className: "e03-legend__item" }); - noneItem.append( - el("span", { className: "e03-legend__swatch e03-swatch-none" }), - t("E03_Verdict_none"), - ); - legend.append(noneItem); - const close = createButton({ label: t("Common_Btn_Close"), variant: "ghost", size: "sm" }); - head.append(el("h3", { className: "e03-card__title", text: t("E03_View3d") }), legend, close); + : `${edges[i]}–${edges[i + 1]}`, + color: `var(--e03-band-${i})`, + })); + items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" }); + const legendRow = el("div", { + className: "e03-legend", + children: [muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items)], + }); const host = el("div", { className: "e03-view3d__canvas" }); - shell.append(head, host); - document.body.append(shell); + let teardown = (): void => undefined; + openModal({ + title: t("E03_View3d"), + closeLabel: t("Common_Btn_Close"), + wide: true, + dialogClass: "e03-view3d", + mount: (body) => { + body.closest(".ui-modal")!.dataset.role = "view3d"; + body.append(legendRow, host); + }, + beforeClose: () => (teardown(), true), + }); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); @@ -190,12 +191,6 @@ export async function openView3d( } }); renderer.dispose(); - shell.remove(); - document.removeEventListener("keydown", onKey); }; - const onKey = (ev: KeyboardEvent): void => { - if (ev.key === "Escape") dispose(); - }; - document.addEventListener("keydown", onKey); - close.addEventListener("click", dispose); + teardown = dispose; } diff --git a/E03_StabSection/E03_StabSection_UI_Walls.css b/E03_StabSection/E03_StabSection_UI_Walls.css index 13c7771c5..91d231296 100644 --- a/E03_StabSection/E03_StabSection_UI_Walls.css +++ b/E03_StabSection/E03_StabSection_UI_Walls.css @@ -13,7 +13,7 @@ stroke: #6b6b6b; } -.e03-wall-swatch { +.ui-legend__mark.e03-wall-swatch { background: repeating-linear-gradient(45deg, #6b6b6b 0 2px, #c8c8c8 2px 5px); } diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 120e4582f..79aa9dfa2 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -82,3 +82,26 @@ def test_e02_parts(): assert "createTable(" in tables and "createTHead" not in tables assert 'className: "ui-field e02-field"' in tables # 칸 이름 줄은 입력 부품의 ui-field assert ".e02-field" not in css and "border-collapse" not in css + + +def test_e03_parts(): + names = [ + "List", "Page", "Plan", "Prob", "ProbChart", "Reinforce", "Result", "Section", "Support", "View3D", + ] # fmt: skip + ts = {n: _src(f"E03_StabSection/E03_StabSection_UI_{n}.ts") for n in names} + css = _src("E03_StabSection/E03_StabSection_UI_Style.css") + for n, src in ts.items(): + assert 'el("table"' not in src, n # 표 = 표 부품(`E03_StabSection_UI_Table.ts`) + assert 'el("input"' not in src and 'el("select"' not in src, n + assert "e03-legend__item" not in src and "e03-legend__swatch" not in src, n + assert "createTable(" in _src("E03_StabSection/E03_StabSection_UI_Table.ts") + assert "dataset.role = role" in _src("E03_StabSection/E03_StabSection_UI_Table.ts") + assert "createLegend(" in _src("E03_StabSection/E03_StabSection_UI_Parts.ts") + assert "openModal(" in ts["View3D"] and 'className: "e03-view3d"' not in ts["View3D"] + for key in ("station-list", "slice-table", "methods", "whatif-table", "support-table"): + assert f'"{key}"' in "".join(ts.values()), key # 촬영 장면 · 시험이 잡는 data-role + for dead in ( + ".e03-legend__item", ".e03-legend__swatch", ".e03-table", ".e03-verdict", ".e03-kpi", + ".e03-view3d {", ".e03-swatch-none", ".e03-list table", ".e03-list th {", + ): # fmt: skip + assert dead not in css, dead From 3e4fcd88450a64fabb5adfc4eae447da11d207d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:22:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(E04):=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20=EA=B0=92=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EB=B2=94=EB=A1=80=20=ED=8C=90=20=C2=B7=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=A7=89=EB=8C=80=20=C2=B7=20=EB=9D=A0=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=84=20=EB=B6=80=ED=92=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20(PLAN=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