From bb6c67deab53144259069b7383424bf7357a3cf0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:34:36 +0900 Subject: [PATCH] =?UTF-8?q?refactor(E06):=20=EC=A0=88=EB=A6=AC=20=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=ED=91=9C=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=B4=EA=B8=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=B2=94=EB=A1=80=20=C2=B7=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=9D=A0=EB=A5=BC=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=EC=9C=BC=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 --- E06_StabRock/E06_StabRock_UI_Joints.ts | 106 +++++++------ E06_StabRock/E06_StabRock_UI_Page.ts | 57 +++---- E06_StabRock/E06_StabRock_UI_Parts.ts | 80 ++++++++++ E06_StabRock/E06_StabRock_UI_Result.ts | 137 +++++++---------- E06_StabRock/E06_StabRock_UI_Stereonet.ts | 31 ++-- E06_StabRock/E06_StabRock_UI_Style.css | 179 ++-------------------- resources/tester/test_553_4_de_parts.py | 29 ++++ 7 files changed, 277 insertions(+), 342 deletions(-) create mode 100644 E06_StabRock/E06_StabRock_UI_Parts.ts diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts index d64d4fecb..447dfe8ab 100644 --- a/E06_StabRock/E06_StabRock_UI_Joints.ts +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -6,8 +6,9 @@ * ========================================================================== */ import { createPanelCard } from "@ui/ui_template_collapsible"; -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { e06Table, note } from "./E06_StabRock_UI_Parts"; export interface JointSet { id: string; @@ -55,7 +56,14 @@ export interface JointsView { rebuild: () => void; // 줄 수가 바뀜 } -/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */ +/** 틀린 칸 표시 — 부품 오류 상태(`is-error` · aria-invalid) */ +function markInvalid(input: HTMLElement, bad: boolean): void { + input.classList.toggle("is-error", bad); + if (bad) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); +} + +/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 오류 표시 · 바꾸면 set 부르고 change */ function numberInput( view: JointsView, value: number | null, @@ -64,17 +72,17 @@ function numberInput( opts: { optional?: boolean; step?: string; path: string }, ): HTMLInputElement { const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; - const input = el("input", { - className: "e06-input", - attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, - }) as HTMLInputElement; + const input = createInputField({ type: "number", size: 24 }).input; + input.classList.add("e06-input"); + input.step = opts.step ?? "any"; + input.dataset.path = opts.path; input.value = value === null ? "" : String(value); input.disabled = !view.editable; const check = (): boolean => { const raw = input.value.trim(); const num = Number(raw); const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; - input.classList.toggle("is-invalid", !ok); + markInvalid(input, !ok); input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; return ok; }; @@ -107,64 +115,61 @@ const COLUMNS: LocaleKey[] = [ function jointTable(view: JointsView): HTMLElement { const wrap = el("div", { className: "e06-table-wrap" }); - const table = el("table", { className: "e06-table" }); - const head = el("tr"); - for (const key of COLUMNS) head.append(el("th", { text: t(key) })); - head.append(el("th")); - table.append(el("thead"), el("tbody")); - table.tHead!.append(head); - view.doc.sets.forEach((row, k) => { - const tr = el("tr", { attrs: { "data-id": row.id } }); - const name = el("input", { - className: "e06-input e06-input--name", - attrs: { type: "text", "data-path": `sets.${k}.name` }, - }) as HTMLInputElement; + const sets = view.doc.sets; + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + const rows = sets.map((row, k) => { + const name = createInputField({ size: 24 }).input; + name.classList.add("e06-input", "e06-input--name"); + name.dataset.path = `sets.${k}.name`; name.value = row.name; name.disabled = !view.editable; name.addEventListener("input", () => { row.name = name.value; - name.classList.toggle("is-invalid", !name.value.trim()); + markInvalid(name, !name.value.trim()); view.change(); }); - tr.append(el("td", { children: [name] })); - const fields: [keyof JointSet, string, boolean][] = [ - ["from_m", "chainage_m", false], - ["to_m", "chainage_m", false], - ["dip_direction", "dip_direction", false], - ["dip", "dip", false], - ["phi", "phi", false], - ["c", "c", false], - ["persistence_m", "persistence_m", true], - ]; + const cells: HTMLElement[] = [name]; for (const [key, limit, optional] of fields) { - const input = numberInput( - view, - row[key] as number | null, - limit, - (v) => ((row as unknown as Record)[key] = v), - { optional, path: `sets.${k}.${key}` }, + cells.push( + numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ), ); - tr.append(el("td", { children: [input] })); } const remove = createButton({ label: "×", variant: "danger-ghost", size: "sm", onClick: () => { - view.doc.sets.splice(k, 1); + sets.splice(k, 1); view.rebuild(); }, }); remove.title = t("E06_Remove"); remove.disabled = !view.editable; - tr.append(el("td", { children: [remove] })); - table.tBodies[0].append(tr); + cells.push(remove); + return cells; }); - wrap.append(table); - if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + const list = e06Table([...COLUMNS.map((key) => t(key)), ""], rows, "e06-table"); + list.rows.forEach((tr, k) => (tr.dataset.id = sets[k].id)); + wrap.append(list.root); + if (!sets.length) wrap.append(note(t("E06_NoSets"))); return wrap; } +/** 좁은 패널 설정 줄 — 설정 줄 부품(줄 52 · 큰 글)은 이 폭에 안 맞아 옛 꼴 그대로 */ function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { const row = el("label", { className: "e06-setting" }); row.append(el("span", { text: t(label) }), control); @@ -178,11 +183,12 @@ function select( set: (v: T) => void, path: string, ): HTMLSelectElement { - const box = el("select", { - className: "e06-input", - attrs: { "data-path": path }, - }) as HTMLSelectElement; - for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + const box = createSelectField({ + options: options.map(([v, key]) => ({ value: v, text: t(key) })), + size: 24, + }).select; + box.classList.add("e06-input"); + box.dataset.path = path; box.value = value; box.disabled = !view.editable; box.addEventListener("change", () => { @@ -241,7 +247,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] { }); add.disabled = !view.editable; setsCard.append( - el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + note(t("E06_JointsHint")), jointTable(view), el("div", { className: "e06-row-actions", children: [add] }), ); @@ -324,8 +330,8 @@ export function buildJointCards(view: JointsView): HTMLElement[] { path: "settings.bolt.plunge", }), ), - el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), - el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }), + note(t("E06_SettingsHint")), + note(t("E06_MinBlockHint")), ); return [setsCard, setCard]; } diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index f96156bd7..fa66bf777 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -10,6 +10,7 @@ import "./E06_StabRock_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { createBanner } from "@ui/ui_template_banner"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, @@ -19,7 +20,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { t } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { @@ -38,6 +39,7 @@ import { type StationEntry, } from "./E06_StabRock_UI_Result"; import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; +import { emptyBox, note, toggle } from "./E06_StabRock_UI_Parts"; interface RockResponse { joints: JointsDoc; @@ -76,33 +78,16 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { - const group = el("div", { className: "e06-toggle" }); - const buttons = options.map(([key, v]) => { - const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); - button.dataset.value = v; - button.classList.toggle("is-on", v === value); - button.addEventListener("click", () => { - buttons.forEach((b) => b.classList.toggle("is-on", b === button)); - onPick(v); - }); - return button; - }); - group.append(...buttons); - return group; -} - export function renderE06StabRock(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); const main = el("div", { className: "e06-page" }); mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); - if (!projectId) - return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + if (!projectId) return void main.append(emptyBox(t("E06_NoProject"))); // ---- 좌 ---- const cards = el("div", { className: "e06-cards" }); - const status = el("p", { className: "e06-status" }); + const status = el("div", { className: "e06-status" }); const saveButton = createButton({ label: t("Common_Btn_Save"), variant: "ghost", @@ -121,11 +106,12 @@ export function renderE06StabRock(root: HTMLElement): void { saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(resetButton, saveButton, analyzeButton); - panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + panel.append(note(t("E06_Note")), cards, status, actions); attachCollapsible(cards); // ---- 가운데 · 우 ---- - const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + const staleBar = createBanner({ tone: "warning", text: t("E06_Stale") }); + staleBar.classList.add("e06-stale"); staleBar.hidden = true; const bandSlot = el("div"); const title = el("strong", { className: "e06-title" }); @@ -140,10 +126,10 @@ export function renderE06StabRock(root: HTMLElement): void { projectionToggle, toggle( [ - ["E06_View_All", "all"], - ["E06_Mode_planar", "planar"], - ["E06_Mode_wedge", "wedge"], - ["E06_Mode_toppling", "toppling"], + [t("E06_View_All"), "all"], + [t("E06_Mode_planar"), "planar"], + [t("E06_Mode_wedge"), "wedge"], + [t("E06_Mode_toppling"), "toppling"], ], netMode, (v) => { @@ -178,14 +164,14 @@ export function renderE06StabRock(root: HTMLElement): void { let active: string | null = null; let shown: StationResponse | null = null; - const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length; const paintStatus = (): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E06_ReadOnly")); if (dirty) lines.push(t("E06_Unsaved")); const bad = invalidCount(); if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); - status.textContent = lines.join("\n"); + status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e06-status__text")] : [])); status.classList.toggle("is-error", bad > 0); }; @@ -211,8 +197,8 @@ export function renderE06StabRock(root: HTMLElement): void { projectionToggle.replaceChildren( toggle( [ - ["E06_Proj_Angle", "equal_angle"], - ["E06_Proj_Area", "equal_area"], + [t("E06_Proj_Angle"), "equal_angle"], + [t("E06_Proj_Area"), "equal_area"], ], projection, (v) => { @@ -227,7 +213,7 @@ export function renderE06StabRock(root: HTMLElement): void { legendSlot.replaceChildren(netLegend(netMode)); if (!shown) { title.textContent = ""; - netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + netSlot.replaceChildren(emptyBox(t("E06_PickStation"))); return; } const e = shown.entry; @@ -247,7 +233,7 @@ export function renderE06StabRock(root: HTMLElement): void { listSlot.replaceChildren( stations.length ? buildList(stations, active, (id) => void select(id)) - : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + : note(t(result ? "E06_NoRock" : "E06_NoResult")), ); detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); paintNet(); @@ -346,7 +332,7 @@ export function renderE06StabRock(root: HTMLElement): void { async function analyze(): Promise { if (!view?.editable) return; analyzeButton.disabled = true; - status.textContent = t("E06_Running"); + status.replaceChildren(note(t("E06_Running"), "e06-status__text")); try { if (dirty && !(await putJoints())) return; const body = await api<{ status: string; summary?: RockResult["summary"] }>( @@ -387,10 +373,7 @@ export function renderE06StabRock(root: HTMLElement): void { await loadResult(); } catch (error) { main.replaceChildren( - el("p", { - className: "e06-empty", - text: error instanceof Error ? error.message : t("Common_Status_Error"), - }), + emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")), ); } })(); diff --git a/E06_StabRock/E06_StabRock_UI_Parts.ts b/E06_StabRock/E06_StabRock_UI_Parts.ts new file mode 100644 index 000000000..60c656784 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Parts.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * E06_StabRock_UI_Parts.ts + * E06 안 부품 걸이(553-4) — 부품(보조 글 · 빈 자리 · 표 · 배지 · 분할 고르기)을 부르고 + * 촬영 장면 · 시험이 잡는 이름(`e06-hint` · `e06-empty` · `e06-table` · `e06-list` · `e06-cases` · `e06-verdict` · `e06-toggle`)만 얹음. + * 모양은 부품 · 이 파일에 CSS 없음. + * ========================================================================== */ + +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createSegment } from "@ui/ui_template_segment"; +import { createTable, type Cell } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; + +/** 보조 글 — 걸이 이름(`e06-hint` · `e06-kin` · `e06-status` …) */ +export function note(text: string, hook = "e06-hint"): HTMLElement { + const node = createText(text, "muted"); + node.classList.add(hook); + return node; +} + +export function emptyBox(text: string): HTMLElement { + const node = createEmpty({ text, small: true }); + node.classList.add("e06-empty"); + return node; +} + +export interface E06Table { + root: HTMLElement; + table: HTMLTableElement; + rows: HTMLTableRowElement[]; +} + +/** 표 부품(데이터표) — `className` = 표에 얹는 걸이 이름 · `role` = data-role */ +export function e06Table( + headers: string[], + rows: Cell[][], + className: string, + role?: string, +): E06Table { + const root = createTable( + headers.map((label) => ({ label })), + rows, + { density: "data" }, + ); + const table = root.querySelector("table") as HTMLTableElement; + table.classList.add(className); + if (role) table.dataset.role = role; + return { root, table, rows: [...table.tBodies[0].rows] }; +} + +const TONE: Record = { + ng: "danger", + check: "warning", + ok: "success", + none: "neutral", +}; + +/** 판정 램프 — verdict = NG · check · OK · none */ +export function verdictLamp(verdict: string, text: string): HTMLElement { + const key = verdict.toLowerCase(); + const lamp = createBadge(text, TONE[key] ?? "neutral", { kind: "lamp" }); + lamp.classList.add("e06-verdict", `e06-verdict--${key}`); + return lamp; +} + +/** 보기 고르기 — 단추 모양은 분할 고르기 부품 · `data-value` 는 그대로 */ +export function toggle( + options: [string, T][], + value: T, + onPick: (v: T) => void, +): HTMLElement { + const group = createSegment({ + options: options.map(([label, v]) => ({ value: v, label })), + value, + size: 24, + onChange: (v) => onPick(v as T), + }); + group.root.classList.add("e06-toggle"); + return group.root; +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts index b0ce05257..5619c350c 100644 --- a/E06_StabRock/E06_StabRock_UI_Result.ts +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -6,6 +6,7 @@ import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { e06Table, note, verdictLamp } from "./E06_StabRock_UI_Parts"; export type Verdict = "NG" | "check" | "OK" | "none"; @@ -66,10 +67,7 @@ const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined ? "–" : v.toFixed(d); function badge(verdict: Verdict): HTMLElement { - return el("span", { - className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, - text: t(`E06_V_${verdict}` as LocaleKey), - }); + return verdictLamp(verdict, t(`E06_V_${verdict}` as LocaleKey)); } /** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */ @@ -119,7 +117,7 @@ export function buildBand( el("span", { text: `${hi.toFixed(0)} m` }), ); box.append(legend, strip, ticks); - } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + } else box.append(legend, note(t("E06_NoRock"))); return box; } @@ -128,34 +126,28 @@ export function buildList( active: string | null, onSelect: (id: string) => void, ): HTMLElement { - const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); - const head = el("tr"); - for (const key of [ - "E06_L_Station", - "E06_L_Side", - "E06_L_Modes", - "E06_L_MinF", - "E06_L_Verdict", - ] as LocaleKey[]) - head.append(el("th", { text: t(key) })); - table.append(el("thead", { children: [head] }), el("tbody")); - for (const e of stations) { + const heads = ["E06_L_Station", "E06_L_Side", "E06_L_Modes", "E06_L_MinF", "E06_L_Verdict"]; + const list = e06Table( + heads.map((key) => t(key as LocaleKey)), + stations.map((e) => [ + e.label ?? e.key, + t(e.slope.side === "left" ? "E06_Left" : "E06_Right"), + `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}`, + fmt(e.min_F), + badge(e.verdict), + ]), + "e06-list", + "station-list", + ); + list.rows.forEach((tr, i) => { + const e = stations[i]; const id = entryId(e); - const tr = el("tr", { - className: id === active ? "is-active" : "", - attrs: { "data-id": id, "data-verdict": e.verdict }, - }); - tr.append( - el("td", { text: e.label ?? e.key }), - el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), - el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }), - el("td", { text: fmt(e.min_F) }), - el("td", { children: [badge(e.verdict)] }), - ); + tr.dataset.id = id; + tr.dataset.verdict = e.verdict; + tr.classList.toggle("is-active", id === active); tr.addEventListener("click", () => onSelect(id)); - table.tBodies[0].append(tr); - } - return table; + }); + return list.root; } export function buildDetail(e: StationEntry): HTMLElement { @@ -163,15 +155,14 @@ export function buildDetail(e: StationEntry): HTMLElement { const s = e.slope; box.append( el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), - el("p", { - className: "e06-hint", - text: t("E06_SlopeLine") + note( + t("E06_SlopeLine") .replace("{dd}", s.dip_direction.toFixed(0)) .replace("{dip}", s.dip.toFixed(1)) .replace("{h}", s.height_m.toFixed(2)) .replace("{up}", s.upper_dip.toFixed(1)) .replace("{g}", e.gamma_r.toFixed(1)), - }), + ), ); const kin = e.kinematic; const kinText = [ @@ -179,57 +170,47 @@ export function buildDetail(e: StationEntry): HTMLElement { `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`, `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`, ]; - box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + box.append(note(kinText.join("\n"), "e06-kin")); if (!e.cases.length) { - box.append( - el("p", { - className: "e06-hint", - text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), - }), - ); + box.append(note(t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"))); return box; } - const table = el("table", { className: "e06-cases" }); - const head = el("tr"); - for (const key of [ + const heads = [ "E06_C_Mode", "E06_C_Joints", "E06_C_Cond", "E06_C_F", "E06_C_Req", "E06_L_Verdict", - ] as LocaleKey[]) - head.append(el("th", { text: t(key) })); - table.append(el("thead", { children: [head] }), el("tbody")); - for (const c of e.cases) { - const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; - const note = !c.formed - ? t("E06_NotFormed") - : c.small - ? t("E06_Small") - : c.persistence_short - ? t("E06_Persist") - : ""; - const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); - tr.append( - el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), - el("td", { text: c.ids.join("·") }), - el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), - el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), - el("td", { text: fmt(c.required) }), - el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }), - ); - table.tBodies[0].append(tr); - } - box.append(table); - if (e.small_blocks) - box.append( - el("p", { - className: "e06-hint", - text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)), - }), - ); - if (kin.toppling.length) - box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + ]; + const cases = e06Table( + heads.map((key) => t(key as LocaleKey)), + e.cases.map((c) => { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const text = !c.formed + ? t("E06_NotFormed") + : c.small + ? t("E06_Small") + : c.persistence_short + ? t("E06_Persist") + : ""; + return [ + t(`E06_Mode_${c.mode}` as LocaleKey), + c.ids.join("·"), + t(`E06_Cond_${c.condition}` as LocaleKey), + `${fmt(c.F, 3)}${text ? ` ${text}` : ""}`, + fmt(c.required), + c.small ? "–" : badge(verdict), + ]; + }), + "e06-cases", + ); + cases.rows.forEach((tr, i) => { + tr.dataset.mode = e.cases[i].mode; + tr.dataset.cond = e.cases[i].condition; + }); + box.append(cases.root); + if (e.small_blocks) box.append(note(t("E06_SmallCount").replace("{n}", String(e.small_blocks)))); + if (kin.toppling.length) box.append(note(t("E06_ToppleNote"))); return box; } diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts index 73e2b9e21..4331c41f5 100644 --- a/E06_StabRock/E06_StabRock_UI_Stereonet.ts +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -6,7 +6,7 @@ * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createLegend } from "@ui/ui_template_flow_legend"; import { t, type LocaleKey } from "@ui/ui_template_locale"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -169,8 +169,19 @@ export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVG return root; } +/** 범례 — 범례 부품(선 · 점선 · 칸 · 점) · 삼각(교선)만 걸이 모양 */ +const LEGEND_MARK: Record = { + slope: { color: "var(--color-text)", kind: "line" }, + friction: { color: "var(--color-text-secondary)", kind: "dash" }, + planar: { color: "color-mix(in srgb, var(--e06-planar) 35%, transparent)", kind: "chip" }, + daylight: { color: "var(--e06-planar)", kind: "dash" }, + wedge: { color: "color-mix(in srgb, var(--e06-wedge) 35%, transparent)", kind: "chip" }, + line: { color: "var(--e06-wedge)", kind: "chip" }, + toppling: { color: "color-mix(in srgb, var(--e06-toppling) 35%, transparent)", kind: "chip" }, + pole: { color: "var(--e06-pole)", kind: "dot" }, +}; + export function netLegend(mode: NetMode): HTMLElement { - const box = el("div", { className: "e06-legend" }); const items: [string, LocaleKey][] = [ ["slope", "E06_Lg_Slope"], ["friction", "E06_Lg_Friction"], @@ -181,13 +192,13 @@ export function netLegend(mode: NetMode): HTMLElement { items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); - for (const [cls, key] of items) { - const row = el("span", { className: "e06-legend__row" }); - row.append( - el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), - el("span", { text: t(key) }), - ); - box.append(row); - } + const box = createLegend( + items.map(([cls, key]) => ({ label: t(key), ...LEGEND_MARK[cls] })), + { vertical: true }, + ); + box.classList.add("e06-legend"); + box.querySelectorAll(".ui-legend__mark").forEach((mark, i) => { + if (items[i][0] === "line") mark.classList.add("e06-legend__mark--line"); + }); return box; } diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css index eb347f792..46c9f7516 100644 --- a/E06_StabRock/E06_StabRock_UI_Style.css +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -12,6 +12,7 @@ --e06-planar: #d03b3b; --e06-wedge: #f1963b; --e06-toppling: #9b6dde; + --e06-pole: #2f6fd6; height: 100dvh; box-sizing: border-box; } @@ -31,39 +32,21 @@ overflow: auto; } -.e06-empty, +/* 보조 글 · 빈 자리 · 띠 알림 · 표 · 설정 줄 · 판정 램프는 부품 — 여기는 자리만 */ +.e06-status__text, .e06-hint, -.e06-status, .e06-kin { - margin: 0; white-space: pre-line; - color: var(--color-text-muted); - font-size: 12px; } .e06-empty { - padding: var(--spacing-16); -} - -.e06-status.is-error { - color: var(--color-danger); -} - -.e06-kin { - color: var(--color-text); + margin: var(--spacing-16); } .e06-panel .ui-sidebar-actions { flex-wrap: wrap; } -.e06-stale { - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); - color: var(--color-text); -} - .e06-stale[hidden] { display: none; } @@ -73,32 +56,8 @@ overflow-x: auto; } -.e06-table { - border-collapse: collapse; - font-size: 12px; -} - -.e06-table th { - padding: 2px 3px; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - white-space: nowrap; -} - -.e06-table td { - padding: 1px 2px; -} - .e06-input { width: 4.2em; - padding: 2px 4px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - font-size: 12px; - box-sizing: border-box; } .e06-input--name { @@ -109,11 +68,6 @@ select.e06-input { width: auto; } -.e06-input.is-invalid { - border-color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); -} - .e06-row-actions { display: flex; justify-content: flex-end; @@ -152,17 +106,6 @@ select.e06-input { color: var(--color-text); } -.e06-toggle { - display: inline-flex; - gap: var(--spacing-4); -} - -.e06-toggle .ui-btn.is-on { - border-color: var(--color-primary); - background: var(--color-primary); - color: var(--color-primary-text); -} - .e06-stage { display: flex; gap: var(--spacing-12); @@ -289,58 +232,9 @@ select.e06-input { color: #0e7490; } -.e06-legend { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--color-text); -} - -.e06-legend__row { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.e06-legend__mark { - display: inline-block; - width: 16px; - height: 10px; - border-radius: 2px; -} - -.e06-legend__mark--slope { - height: 3px; - background: var(--color-text); -} -.e06-legend__mark--friction { - height: 0; - border-top: 1px dashed var(--color-text-secondary); -} -.e06-legend__mark--daylight { - height: 0; - border-top: 1px dashed var(--e06-planar); -} -.e06-legend__mark--planar { - background: color-mix(in srgb, var(--e06-planar) 35%, transparent); -} -.e06-legend__mark--wedge { - background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); -} -.e06-legend__mark--toppling { - background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); -} .e06-legend__mark--line { - width: 10px; - background: var(--e06-wedge); clip-path: polygon(50% 0, 100% 100%, 0 100%); } -.e06-legend__mark--pole { - width: 10px; - border-radius: 50%; - background: #2f6fd6; -} /* ---- 위험 띠 ---- */ .e06-band { @@ -418,74 +312,25 @@ select.e06-input { overflow: auto; } -.e06-list, -.e06-cases { - width: 100%; - border-collapse: collapse; - font-size: 12px; - color: var(--color-text); +.e06-list-slot .ui-table-wrap { + overflow: visible; + border: 0; + border-radius: 0; + background: none; } -.e06-list th, -.e06-cases th { +.e06-list-slot th { position: sticky; top: 0; - padding: 3px 4px; - background: var(--color-surface); - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - text-align: left; -} - -.e06-list td, -.e06-cases td { - padding: 3px 4px; - border-top: 1px solid var(--color-border); + z-index: 1; + background: var(--color-surface-raised); } .e06-list tbody tr { cursor: pointer; } -.e06-list tbody tr:hover { - background: var(--color-surface-raised); -} - -.e06-list tr.is-active { - background: color-mix(in srgb, var(--color-primary) 16%, transparent); -} - .e06-detail h4 { margin: 0 0 4px; color: var(--color-text); } - -.e06-verdict { - display: inline-flex; - align-items: center; - gap: 4px; - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.e06-verdict::before { - content: ""; - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--e06-none); -} - -.e06-verdict--ng::before { - background: var(--e06-ng); - border-radius: 2px; - transform: rotate(45deg); -} -.e06-verdict--ok::before { - background: var(--e06-ok); -} -.e06-verdict--check::before { - background: var(--e06-check); - border-radius: 0; - clip-path: polygon(50% 0, 100% 100%, 0 100%); -} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index cc714073b..9b1af02f5 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -145,3 +145,32 @@ def test_e05_parts(): assert ".e05-stale[hidden]" in css # 띠 알림은 flex 라 hidden 을 덮음 for dead in (".e05-preview {", ".e05-preview__head", ".e05-status.is-error"): assert dead not in css, dead + + +def test_e06_parts(): + names = ["Joints", "Page", "Result", "Stereonet"] + ts = {n: _src(f"E06_StabRock/E06_StabRock_UI_{n}.ts") for n in names} + parts = _src("E06_StabRock/E06_StabRock_UI_Parts.ts") + css = _src("E06_StabRock/E06_StabRock_UI_Style.css") + for n, src in ts.items(): + assert 'el("table"' not in src and 'el("input"' not in src, n + assert 'el("select"' not in src and 'el("p"' not in src, n + for part in ("createTable(", "createBadge(", "createSegment(", "createText(", "createEmpty("): + assert part in parts, part + assert "createLegend(" in ts["Stereonet"] and "createBanner(" in ts["Page"] + assert "createInputField(" in ts["Joints"] and "createSelectField(" in ts["Joints"] + assert ( + 'classList.add("e06-toggle")' in parts + ) # 촬영 장면이 `.e06-toggle button[data-value]` 로 잡음 + assert '"station-list"' in ts["Result"] # 촬영 장면 · 시험이 잡는 data-role + for dead in ( + ".e06-verdict", + ".e06-list th", + ".e06-cases", + ".e06-table th", + ".e06-legend__row", + ".e06-toggle .ui-btn", + "is-invalid", + ): + assert dead not in css, dead + assert "is-invalid" not in ts["Joints"] + ts["Page"]