From 57ec6d247b641053664d3d0717ccae3e23ce805e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:19:10 +0900 Subject: [PATCH] =?UTF-8?q?refactor(E03):=20=ED=91=9C=20=C2=B7=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=C2=B7=20=EB=B3=B4=EC=A1=B0=20=EA=B8=80=20=C2=B7=20?= =?UTF-8?q?=EB=B9=88=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=C2=B7=20=EB=B2=94=EB=A1=80=20=C2=B7=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=EB=9E=A8=ED=94=84=20=C2=B7=20=EB=B3=84=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=C2=B7=20=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20=C2=B7=203D=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=84=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20(PL?= =?UTF-8?q?AN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- 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