refactor(E03): 표 · 카드 · 보조 글 · 빈 자리 · 체크 · 범례 · 판정 램프 · 별 단추 · 값 목록 · 3D 창을 부품으로 (PLAN 553-4)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:19:10 +09:00
co-authored by Claude Sonnet 5.5
parent eaf5079736
commit 57ec6d247b
16 changed files with 503 additions and 664 deletions
+70 -71
View File
@@ -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<string, BadgeTone> = { 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;
}
+13 -30
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
const sleep = (ms: number): Promise<void> => 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(
@@ -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;
}
+9 -3
View File
@@ -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);
@@ -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));
+52 -63
View File
@@ -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;
@@ -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 = 위 끝) · 나머지 변수 평균 · 임계면 고정 */
+51 -75
View File
@@ -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<WhatIfRecord, "before" | "after">): 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") }),
);
+69 -78
View File
@@ -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 =>
+27 -26
View File
@@ -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;
}
+21 -207
View File
@@ -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);
}
+34 -62
View File
@@ -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";
@@ -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] };
}
+26 -31
View File
@@ -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<void> {
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<HTMLElement>(".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;
}
+1 -1
View File
@@ -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);
}
+23
View File
@@ -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