Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-26 22:26:59 +09:00
41 changed files with 779 additions and 1150 deletions
+16 -56
View File
@@ -109,7 +109,7 @@
bottom: 40px;
z-index: 20;
padding: 6px 10px;
border-radius: 4px;
border-radius: var(--radius-buttons);
background: var(--color-primary);
color: var(--color-primary-text);
}
@@ -126,6 +126,10 @@
cursor: pointer;
}
.ss-brush:hover {
background: var(--color-hover);
}
.ss-brush.is-on {
background: color-mix(in srgb, var(--color-focus-ring) 20%, var(--color-surface-raised));
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
@@ -135,19 +139,17 @@
cursor: copy;
}
.ss-find {
position: absolute;
/* 찾기 = 떠 있는 판 부품(머리 없음) · 칸 · 단추 = 부품 — 여기는 자리 · 줄 배치만(553-6) */
.ss-find.ui-float-panel {
top: 4px;
right: 16px;
z-index: 20;
}
.ss-find > .ui-float-panel__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-find[hidden],
@@ -166,32 +168,17 @@
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-body);
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
.ss-find__opt {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-size: var(--text-caption);
}
.ss-find__status {
font-size: 12px;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
@@ -223,19 +210,11 @@
gap: 2px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
cursor: pointer;
}
/* 함수 자동 완성 · 인자 도움말 */
.ss-hint {
position: absolute;
@@ -265,22 +244,17 @@
}
.ss-hint__item.is-on {
background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-raised));
background: var(--color-accent-soft);
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
.ss-dialog {
position: absolute;
/* 패널 창 = 떠 있는 판 부품 — 여기는 자리 · 크기만(553-6) */
.ss-dialog.ui-float-panel {
top: 4px;
right: 16px;
z-index: 21;
max-width: min(720px, calc(100% - 32px));
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-dialog[hidden],
@@ -288,20 +262,6 @@
display: none;
}
.ss-dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
font-weight: bold;
}
.ss-dialog__body {
padding: 8px;
}
.ss-list-arrow {
position: absolute;
z-index: 8;
@@ -118,10 +118,15 @@ export function dialogKeys(overlay: HTMLElement, close: () => void, run?: () =>
});
}
/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
/** body 에 뜬 창(.ui-confirm · 모달)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */
export function watchClose(back: () => void): void {
const w = new MutationObserver(() => {
if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return;
if (
document.querySelector(
"body > .ui-confirm, body > .ui-confirm__panel, body > .ui-modal__backdrop",
)
)
return;
w.disconnect();
back();
});
@@ -63,7 +63,7 @@
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
background: var(--color-scrim);
}
.ss-steps__box {
@@ -115,18 +115,10 @@
overflow: visible;
}
.ss-errtip__btn {
/* ⚠ = 아이콘 단추 부품(선 · 18) — 여기는 자리 · 경고 색만(553-6) */
.ss-errtip__btn.ui-icon-btn {
position: absolute;
width: 18px;
height: 18px;
padding: 0;
font-size: 11px;
line-height: 16px;
cursor: pointer;
color: var(--color-warning);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-errtip__balloon {
@@ -183,5 +175,5 @@
}
.ss-errtip__item:hover {
background: var(--color-mist-violet);
background: var(--color-hover);
}
@@ -12,6 +12,7 @@
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { autoSumCells, autoSumRange } from "./spreadsheet_autosum";
import { isError } from "./spreadsheet_eval";
@@ -40,7 +41,6 @@ const TEXT = {
validation: "데이터 유효성…",
names: "이름 관리…",
grid: "눈금선 보이기 · 숨기기",
close: "닫기",
list: "값 목록(Alt+↓)",
sheets: "시트 숨기기 · 탭 색…",
audit: "수식 분석",
@@ -121,18 +121,14 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
};
// ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ─────────────────────
const title = el("span", { className: "ss-dialog__title" });
const body = el("div", { className: "ss-dialog__body" });
const shut = el("button", {
className: "ss-find__btn",
text: "×",
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog ui-popup",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
// 떠 있는 판 부품(머리 · 닫기 ✕ · 몸 · 553-6) — 자리는 `.ss-dialog`
const { root: dialog, body } = createFloatPanel({
title: " ",
onClose: () => (closePanel(), back()),
});
dialog.classList.add("ss-dialog");
dialog.setAttribute("hidden", "");
const title = dialog.querySelector(".ui-float-panel__title") as HTMLElement;
ctx.root.append(dialog);
let panel: PartHandle | null = null;
const closePanel = (): void => {
@@ -141,7 +137,6 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
body.replaceChildren();
dialog.setAttribute("hidden", "");
};
shut.addEventListener("click", () => (closePanel(), back()));
dialog.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
@@ -11,7 +11,7 @@
* 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createIconButton, el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { toA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
@@ -137,11 +137,9 @@ export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): Er
const g = ctx.grid.root.getBoundingClientRect();
layer.style.left = `${box.x - (a.left - g.left) - 20}px`;
layer.style.top = `${box.y - (a.top - g.top)}px`;
const btn = el("button", {
className: "ss-errtip__btn",
text: "⚠",
attrs: { type: "button", "aria-label": w.까닭 },
});
const btn = createIconButton({ glyph: "⚠", title: w.까닭, variant: "outline", size: 18 });
btn.classList.add("ss-errtip__btn");
btn.removeAttribute("title"); // 까닭은 옆 풍선이 보임
const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 });
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c)));
+3 -5
View File
@@ -6,7 +6,7 @@
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { watchClose } from "./spreadsheet_actions";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
@@ -138,10 +138,8 @@ export function attachExtras(
: undefined;
picker.replaceChildren(
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
const b = el("button", {
text: TEXT.modes[mode],
attrs: { type: "button", "data-mode": mode },
});
const b = createButton({ label: TEXT.modes[mode], variant: "ghost", size: 22 });
b.dataset.mode = mode;
b.addEventListener("click", () => {
picker.setAttribute("hidden", "");
// 늦게 — 크기 예산
@@ -4,7 +4,8 @@
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createButton, createIconButton, el, setUiSize } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
import { selectCell } from "./spreadsheet_selection";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -29,12 +30,14 @@ export interface FindPanelHandle extends PartHandle {
}
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
const field = (placeholder: string): HTMLInputElement =>
setUiSize(el("input", { className: "ui-input ss-find__input", attrs: { placeholder } }), 24);
const query = field(TEXT.find);
const repl = field(TEXT.replace);
const status = el("span", { className: "ss-find__status" });
const button = (text: string, onClick: () => void): HTMLButtonElement => {
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
b.addEventListener("click", onClick);
const b = createButton({ label: text, variant: "ghost", size: 22, onClick });
b.classList.add("ss-find__btn");
return b;
};
const check = (text: string): HTMLInputElement => {
@@ -42,8 +45,8 @@ export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindP
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
return box;
};
const closeBtn = button("×", close);
closeBtn.title = TEXT.close;
const closeBtn = createIconButton({ glyph: "×", title: TEXT.close, size: 22, onClick: close });
closeBtn.classList.add("ss-find__btn");
const options = el("div", { className: "ss-find__row" });
const caseBox = check(TEXT.case);
const wholeBox = check(TEXT.whole);
@@ -52,19 +55,19 @@ export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindP
className: "ss-find__row",
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
});
const root = el("div", {
className: "ss-find",
attrs: { hidden: "" },
children: [
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
],
});
// 떠 있는 판 부품(머리 없음 · 553-6) — 자리는 `.ss-find`
const { root, body } = createFloatPanel({ popover: true });
root.classList.add("ss-find");
root.setAttribute("hidden", "");
body.append(
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
);
ctx.root.append(root);
const opts = (): FindOptions => ({
@@ -1,50 +1,9 @@
/* =============================================================================
* spreadsheet_format_dialog.css (2단계 · 주인 sub_laptop_1)
* 셀 서식 대화상자 — 뿌리 배경(`.ui-confirm`)은 공용 확인 모달과 같은 것을 그대로 씀.
* 셀 서식 대화상자 — 창 · 탭 · 칸 = 부품 모달(40rem) · 탭(32) · 입력 · 고르기(28) · 여기는 안 배치만(553-6).
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
* ========================================================================== */
.ss-fmtdlg__panel {
display: flex;
flex-direction: column;
width: min(560px, 92vw);
max-height: 84vh;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-fmtdlg__title {
margin: 0 0 var(--spacing-8);
font-size: 1rem;
color: var(--color-text-body);
}
.ss-fmtdlg__tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid var(--color-border);
}
.ss-fmtdlg__tabbtn {
padding: 6px 12px;
border: 1px solid var(--color-border);
border-bottom: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
background: var(--color-paper);
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.ss-fmtdlg__tabbtn.is-active {
background: var(--color-canvas);
color: var(--color-text-body);
font-weight: 600;
}
.ss-fmtdlg__body {
flex: 1;
min-height: 300px;
@@ -68,21 +27,7 @@
width: 140px;
flex-shrink: 0;
color: var(--color-text-secondary);
font-size: 0.82rem;
}
.ss-fmtdlg__row select,
.ss-fmtdlg__row input[type="text"],
.ss-fmtdlg__row input:not([type]),
.ss-fmtdlg__row input[type="number"],
.ss-fmtdlg__code {
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font-size: 0.82rem;
font-size: var(--text-body-sm);
}
.ss-fmtdlg__code {
@@ -135,7 +80,7 @@
.ss-fmtdlg__note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 0.8rem;
font-size: var(--text-caption);
}
.ss-fmtdlg__actions {
@@ -12,7 +12,9 @@
* 우클릭 메뉴 [셀 서식] 도 같은 함수를 부르면 됨(`spreadsheet_menu.ts`).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, el, setUiSize } from "@ui/ui_template_elements";
import { createTabs } from "@ui/ui_template_tabs";
import { openModal } from "@ui/ui_template_modal";
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import {
@@ -39,6 +41,7 @@ const TEXT = {
tabProtect: "보호",
ok: "확인",
cancel: "취소",
close: "닫기",
category: "범주",
decimals: "소수 자릿수",
thousands: "천 단위 구분 기호 사용",
@@ -105,17 +108,9 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
const spansMany = range.r0 !== range.r1 || range.c0 !== range.c1;
let mergeWanted = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
const overlay = el("div", { className: "ui-confirm ss-fmtdlg" });
const panel = el("div", { className: "ss-fmtdlg__panel" });
const tabsBar = el("div", { className: "ss-fmtdlg__tabs" });
const body = el("div", { className: "ss-fmtdlg__body" });
overlay.append(panel);
panel.append(el("h2", { className: "ss-fmtdlg__title", text: TEXT.title }), tabsBar, body);
const close = (): void => overlay.remove();
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) close();
});
let close = (): void => {};
// ── 표시 형식 ────────────────────────────────────────────────────────
let category: Category = guessCategory(draft.형식);
@@ -464,22 +459,25 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
[TEXT.tabProtect, protectTab],
];
function showTab(index: number): void {
body.replaceChildren(tabs[index][1]());
tabsBar.querySelectorAll(".ss-fmtdlg__tabbtn").forEach((btn, i) => {
btn.classList.toggle("is-active", i === index);
});
}
tabs.forEach(([label], i) => {
const btn = el("button", {
className: "ss-fmtdlg__tabbtn",
text: label,
attrs: { type: "button" },
});
btn.addEventListener("click", () => showTab(i));
tabsBar.append(btn);
const tabBar = createTabs({
tabs: tabs.map(([label], i) => ({ key: String(i), label })),
value: "0",
size: 32,
onChange: (key) => showTab(Number(key)),
});
tabsBar.append(tabBar.root);
function showTab(index: number): void {
const tab = tabs[index][1]();
// 칸 = 부품 입력 · 고르기 28(553-6)
const fields = "select, input:not([type]), input[type=text], input[type=number]";
for (const f of tab.querySelectorAll<HTMLElement>(fields)) {
f.classList.add(f.tagName === "SELECT" ? "ui-select" : "ui-input");
setUiSize(f, 28);
}
body.replaceChildren(tab);
tabBar.select(String(index));
}
function apply(): void {
ctx.dispatch({ 종류: "서식", 시트: ctx.selection.시트, 범위: ctx.selection.범위, 바꿀: draft });
@@ -497,13 +495,21 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
const actions = el("div", {
className: "ss-fmtdlg__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => close() }),
createButton({ label: TEXT.ok, variant: "filled", onClick: apply }),
],
});
panel.append(actions);
showTab(0);
document.body.append(overlay);
dialogKeys(overlay, close, apply);
openModal({
title: TEXT.title,
closeLabel: TEXT.close,
width: "40rem",
dialogClass: "ss-fmtdlg",
mount: (box, shut) => {
close = shut;
box.append(tabsBar, body, actions);
dialogKeys(box.parentElement ?? box, shut, apply);
},
});
}
+2 -11
View File
@@ -120,20 +120,11 @@
width: 320px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.ss-links__search {
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
}
.ss-links__list {
display: flex;
flex-direction: column;
@@ -155,7 +146,7 @@
}
.ss-links__row:hover {
background: var(--color-mist-violet);
background: var(--color-hover);
}
.ss-links__note {
+2 -2
View File
@@ -585,8 +585,8 @@ export function mountLinks(
function openPick(anchor: HTMLElement): void {
closePick();
const input = el("input", {
className: "ss-links__search",
attrs: { type: "search", placeholder: TEXT.search },
className: "ui-input ss-links__search",
attrs: { type: "search", placeholder: TEXT.search, "data-size": "28" },
}) as HTMLInputElement;
const list = el("div", { className: "ss-links__list" });
const note = (text: string) => el("div", { className: "ss-links__note", text });
+4 -4
View File
@@ -59,15 +59,15 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
* 문제는 `showConfirmDialog` 머리 주석과 같은 까닭). 저장을 누르면 `onSave`. */
function openCommentEditor(initial: string, onSave: (text: string) => void): void {
const overlay = el("div", { className: "ss-comment-editor" });
const panel = el("div", { className: "ss-comment-editor__panel" });
const overlay = el("div", { className: "ui-confirm ss-comment-editor" });
const panel = el("div", { className: "ui-confirm__panel" });
const textarea = el("textarea", {
className: "ss-comment-editor__input",
className: "ui-input ss-comment-editor__input",
}) as HTMLTextAreaElement;
textarea.value = initial;
const done = (): void => overlay.remove();
const actions = el("div", {
className: "ss-comment-editor__actions",
className: "ui-confirm__actions",
children: [
createButton({ label: "취소", variant: "ghost", onClick: done }),
createButton({
@@ -100,12 +100,17 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
function drawKeys() {
list.replaceChildren(
...keys.map((k, i) => {
const col = el("select", { attrs: { "aria-label": t("Column") } });
const col = el("select", {
className: "ui-select",
attrs: { "aria-label": t("Column"), "data-size": "28" },
});
for (let c = rg.c0; c <= rg.c1; c++)
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
col.value = String(k.열);
col.addEventListener("change", () => (k.열 = Number(col.value)));
const dir = el("select", {
className: "ui-select",
attrs: { "data-size": "28" },
children: [
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
@@ -7,7 +7,7 @@
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
background: var(--color-scrim);
}
.ss-sort__box,
@@ -48,11 +48,6 @@
.ss-sort__row select {
flex: 1;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: inherit;
}
.ss-sort__row option {
@@ -92,8 +87,8 @@
}
.ss-filter__btn.is-on {
color: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
color: var(--color-accent);
border-color: var(--color-accent);
}
.ss-filter__panel {
@@ -130,5 +125,5 @@
}
.ss-filter__link:hover {
background: var(--color-mist-violet);
background: var(--color-hover);
}
@@ -14,7 +14,7 @@
padding: var(--spacing-8);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-cards);
min-width: 280px;
}
@@ -89,7 +89,7 @@
}
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
background: var(--color-hover);
}
.ss-statusbar {
+5 -34
View File
@@ -215,42 +215,13 @@
/* ── 메모 편집 창(칸 우클릭 [메모 삽입 · 편집]) ──────────────────────────────── */
.ss-comment-editor {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-scrim);
}
.ss-comment-editor__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 280px;
padding: var(--spacing-16);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
/* 창 = 공용 확인창(.ui-confirm) · 칸 = 부품 입력(여러 줄) — 여기는 칸 크기만(553-6) */
.ss-comment-editor__input {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 80px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
}
.ss-comment-editor__actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
margin-bottom: var(--spacing-16);
}
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
+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);
}
+87 -64
View File
@@ -10,9 +10,18 @@
import "./E04_Stab3D_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createBanner } from "@ui/ui_template_banner";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createKv, type KvRow } from "@ui/ui_template_kv";
import { createLegend } from "@ui/ui_template_flow_legend";
import { createListRow } from "@ui/ui_template_list_row";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createProgressBar } from "@ui/ui_template_progress";
import { createSegment } from "@ui/ui_template_segment";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D";
@@ -86,18 +95,36 @@ async function fetch2d(projectId: string): Promise<Station2d[]> {
}
}
/** 안내 글 한 줄 — 걸이 이름(`e04-hint` 같은)만 얹음 */
function note(text: string, hook: string): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e04-empty");
return node;
}
/** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */
function legend(layer: MapLayer): HTMLElement {
const box = el("div", { className: "e04-legend" });
box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") }));
const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0);
for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) {
const item = el("span", { className: "e04-legend__row" });
const swatch = el("i", { className: "e04-legend__swatch" });
swatch.style.background = row.color;
item.append(swatch, el("span", { text: row.label }));
box.append(item);
}
return box;
const panel = createFloatPanel({
title: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume"),
});
panel.root.classList.add("e04-legend");
panel.body.append(
createLegend(
[...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }].map((row) => ({
label: row.label,
color: row.color,
})),
{ vertical: true },
),
);
return panel.root;
}
export function renderE04Stab3D(root: HTMLElement): void {
@@ -105,8 +132,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
const panel = el("div", { className: "ui-sidebar-panel e04-panel" });
const main = el("div", { className: "e04-page" });
mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") }));
if (!projectId) return void main.append(emptyBox(t("E04_NoProject")));
// ---- 가운데 ----
const map = createPlanMap();
@@ -115,19 +141,14 @@ export function renderE04Stab3D(root: HTMLElement): void {
let mode: "plan" | "3d" = "plan";
let layer: MapLayer = "fs";
const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => {
const group = el("div", { className: "e04-toggle" });
const buttons = labels.map(([key, value], i) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = value;
button.classList.toggle("is-on", i === 0);
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(value);
});
return button;
const group = createSegment({
options: labels.map(([key, value]) => ({ value, label: t(key) })),
value: labels[0][1],
size: 24,
onChange: onPick,
});
group.append(...buttons);
return group;
group.root.classList.add("e04-toggle");
return group.root;
};
const legendSlot = el("div", { className: "e04-legend-slot" });
const paintLegend = (): void => legendSlot.replaceChildren(legend(layer));
@@ -183,8 +204,8 @@ export function renderE04Stab3D(root: HTMLElement): void {
const sectionCard = createPanelCard(t("E04_Card_Sections"), false);
const settings = buildSettingsCards();
sectionCard.append(sectionList, settings.rangeRow);
const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e04-progress__text" });
const progressBar = createProgressBar();
const progressText = note("", "e04-progress__text");
const cancelButton = createButton({
label: t("E04_Cancel"),
variant: "danger-ghost",
@@ -192,7 +213,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }),
});
const progressBox = el("div", { className: "e04-progress-box" });
progressBox.append(progressBar, progressText, cancelButton);
progressBox.append(progressBar.root, progressText, cancelButton);
progressBox.hidden = true;
const analyzeButton = createButton({
label: t("E04_Analyze"),
@@ -207,10 +228,10 @@ export function renderE04Stab3D(root: HTMLElement): void {
analyzeButton.disabled = reanalyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") });
const readOnly = note(t("E04_ReadOnly"), "e04-hint");
readOnly.hidden = true;
panel.append(
el("p", { className: "e04-hint", text: t("E04_Hint") }),
note(t("E04_Hint"), "e04-hint"),
readOnly,
sectionCard,
settings.basicCard,
@@ -258,31 +279,25 @@ export function renderE04Stab3D(root: HTMLElement): void {
...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key);
const button = el("button", {
className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`,
attrs: { type: "button", "data-key": key },
const row = createListRow({
label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
trailing: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent",
active: key === sectionKey(start_m, end_m),
onClick: () => settings.setRange(section.start_m, section.end_m),
});
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent";
button.append(
chip,
el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }),
el("span", {
className: "e04-section__value",
text: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
}),
);
button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m));
return button;
row.classList.add("e04-section");
row.dataset.key = key;
return row;
}),
);
}
function paintResult(): void {
if (!result) {
resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") }));
resultBody.replaceChildren(emptyBox(t("E04_Result_None")));
return;
}
const s = result.summary;
@@ -319,20 +334,33 @@ export function renderE04Stab3D(root: HTMLElement): void {
["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
];
const list = el("dl", { className: "e04-result__list" });
for (const [key, value, color] of rows) {
const dd = el("dd", { text: value, attrs: { "data-field": key } });
if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
const kv: KvRow[] = rows.map(([key, value, color]) => {
const cell = document.createDocumentFragment();
if (color) {
const chip = el("i", { className: "e04-section__chip" });
chip.style.background = color;
dd.prepend(chip);
cell.append(chip);
}
list.append(el("dt", { text: t(key) }), dd);
}
cell.append(value);
return {
label: t(key),
value: cell,
...(key === "E04_Result_Verdict" ? { ok: s.verdict === "ok" } : {}),
};
});
const list = createKv(kv);
list.classList.add("e04-result__list");
list.querySelectorAll("dd").forEach((dd, i) => {
dd.setAttribute("data-field", rows[i][0]);
if (rows[i][0] === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`);
});
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") }));
notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") }));
if (result.stale) {
const warn = createBanner({ tone: "warning", text: t("E04_Result_Stale") });
warn.classList.add("e04-warn");
notes.push(warn);
}
notes.push(note(t("E04_Result_Note"), "e04-note"));
resultBody.replaceChildren(list, ...notes);
}
@@ -362,8 +390,8 @@ export function renderE04Stab3D(root: HTMLElement): void {
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`;
progressBar.set(p.percent / 100);
progressText.replaceChildren(`${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`);
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
@@ -404,12 +432,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e04-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
main.replaceChildren(emptyBox(error instanceof Error ? error.message : String(error)));
return;
}
const ov = overview!;
+6 -119
View File
@@ -29,17 +29,6 @@
flex-wrap: wrap;
}
.e04-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e04-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e04-stage {
position: relative;
flex: 1 1 auto;
@@ -99,23 +88,10 @@
right: var(--spacing-8);
}
/* 판 · 범례 · 목록 줄 · 값 목록 · 띠 알림은 부품 — 여기는 자리만 */
.e04-view3d__tools {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
display: flex;
gap: var(--spacing-12);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: 12px;
}
.e04-view3d__toggle {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.e04-legend-slot {
@@ -125,30 +101,16 @@
pointer-events: none;
}
.e04-legend {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
font-size: 12px;
.e04-legend-slot .e04-legend {
position: static;
}
.e04-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e04-legend__swatch,
.e04-section__chip {
display: inline-block;
width: 14px;
height: 10px;
flex: none;
margin-right: var(--spacing-4);
border-radius: 2px;
border: 1px solid var(--color-border);
}
@@ -156,10 +118,7 @@
/* 좌측 */
.e04-hint,
.e04-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
margin-bottom: var(--spacing-8);
}
.e04-sections {
@@ -170,35 +129,6 @@
overflow: auto;
}
.e04-section {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.e04-section:hover {
background: var(--color-surface-raised);
}
.e04-section.is-active {
border-color: var(--color-primary);
}
.e04-section__value {
color: var(--color-text-muted);
font-size: 12px;
}
.e04-range .ui-field,
.e04-advanced .ui-field {
min-width: 0;
@@ -229,58 +159,15 @@
display: none;
}
.e04-progress {
width: 100%;
}
.e04-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e04-result {
padding: var(--spacing-12);
}
.e04-result__list {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e04-result__list dt {
color: var(--color-text-muted);
}
.e04-result__list dd {
display: flex;
align-items: center;
gap: var(--spacing-8);
margin: 0;
color: var(--color-text);
font-weight: 600;
justify-content: flex-end;
}
.e04-result__list dd.is-ok {
color: var(--color-success);
}
.e04-result__list dd.is-ng {
color: var(--color-danger);
}
.e04-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
margin-top: var(--spacing-12);
}
.e04-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
+9 -7
View File
@@ -9,7 +9,9 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import {
@@ -46,13 +48,13 @@ export function createView3D(): View3D {
const canvas = el("canvas", { className: "e04-view3d__canvas" });
const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" });
// [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표
const slipToggle = el("input", { attrs: { type: "checkbox" } });
slipToggle.checked = true;
const tools = el("div", { className: "e04-view3d__tools" });
const label = el("label", { className: "e04-view3d__toggle" });
label.append(slipToggle, el("span", { text: t("E04_SlipSurface") }));
tools.append(label);
root.append(canvas, compass.root, tools);
const slipBox = createCheckbox(t("E04_SlipSurface"), { checked: true });
const slipToggle = slipBox.input;
slipBox.root.classList.add("e04-view3d__toggle");
const tools = createFloatPanel({ popover: true });
tools.root.classList.add("e04-view3d__tools");
tools.body.append(slipBox.root);
root.append(canvas, compass.root, tools.root);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio || 1);
+51
View File
@@ -82,3 +82,54 @@ 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
def test_e04_parts():
page = _src("E04_Stab3D/E04_Stab3D_UI_Page.ts")
view = _src("E04_Stab3D/E04_Stab3D_UI_View3D.ts")
css = _src("E04_Stab3D/E04_Stab3D_UI_Style.css")
assert 'el("progress"' not in page and "createProgressBar()" in page
for part in (
"createSegment(",
"createListRow(",
"createKv(",
"createFloatPanel(",
"createBanner(",
):
assert part in page, part
assert 'el("input"' not in view and "createCheckbox(" in view
assert (
'classList.add("e04-toggle")' in page
) # 촬영 장면이 `.e04-toggle button[data-value]` 로 잡음
assert 'row.classList.add("e04-section")' in page
for dead in (
".e04-toggle .ui-btn",
".e04-legend__row",
".e04-legend__swatch",
".e04-result__list",
".e04-section:hover",
):
assert dead not in css, dead
+3 -3
View File
@@ -81,7 +81,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template
out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음)
// ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음
await tabs.select("c");
const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x");
const xc = tabNode("c").kids.find((k) => k.className.split(" ").includes("cad-tabs__x"));
await xc.on.click({ stopPropagation() {} });
out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length };
// ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음
@@ -97,7 +97,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template
await closer.open({ id: "d1", name: "종단" });
const before = confirms.length;
const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2");
await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} });
await d2.kids.find((k) => k.className.split(" ").includes("cad-tabs__x")).on.click({ stopPropagation() {} });
const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음
out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last };
@@ -137,7 +137,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template
});
await new Promise((r) => setTimeout(r, 5));
const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs");
const plus = bar.kids.find((k) => k.className === "cad-tabs__add");
const plus = bar.kids.find((k) => k.className.split(" ").includes("cad-tabs__add"));
await plus.on.click();
await new Promise((r) => setTimeout(r, 5));
inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림
+4 -6
View File
@@ -24,7 +24,7 @@
padding: 10px 18px;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
box-shadow: var(--shadow-lg);
color: var(--color-text);
font-size: 13px;
}
@@ -38,10 +38,10 @@
transform: translateX(-50%);
max-width: 70%;
padding: 8px 16px;
border: 1px solid var(--color-danger, #d33);
border: 1px solid var(--color-danger);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
color: var(--color-danger, #d33);
color: var(--color-danger);
font-size: 12px;
}
@@ -86,7 +86,7 @@
}
.cad-frame-edit__label {
font-size: 0.78rem;
font-size: var(--text-caption);
line-height: 1.4;
color: var(--color-text);
}
@@ -99,8 +99,6 @@
.cad-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 0.78rem;
}
/* 작도 영역 숫자 칸 넷(169-1 · `cad_host_area.ts`) — 두 줄 두 칸 */
@@ -86,6 +86,7 @@ export function createFrameTemplateEditor<D extends CadHostDrawing>(
const finishButton = createButton({
label: "완료",
variant: "filled",
size: 24,
onClick: () => void finish(),
});
/**
@@ -95,6 +96,7 @@ export function createFrameTemplateEditor<D extends CadHostDrawing>(
const resetButton = createButton({
label: "기본 도각 복원",
variant: "ghost",
size: 24,
onClick: () => void resetToDefault(),
});
/**
@@ -111,12 +113,14 @@ export function createFrameTemplateEditor<D extends CadHostDrawing>(
const importButton = createButton({
label: "파일 불러오기",
variant: "ghost",
size: 24,
onClick: () => fileInput.click(),
});
const cancelButton = createButton({
label: "취소",
variant: "ghost",
size: 24,
onClick: () => leave(),
});
const bannerButtons = document.createElement("div");
+1 -19
View File
@@ -66,28 +66,10 @@
font: inherit;
}
.cad-tabs__x,
.cad-tabs__add {
padding: 0 4px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.cad-tabs__x:hover,
.cad-tabs__add:hover {
background: var(--color-surface-raised);
color: var(--color-text);
}
/* × · + = 아이콘 단추 부품(18 · 22) — 여기는 자리만(553-6) */
.cad-tabs__add {
align-self: center;
margin-left: 2px;
font-size: 16px;
}
.cad-tabs[data-busy="true"] .cad-tabs__tab {
+4 -2
View File
@@ -233,7 +233,8 @@ export function createCadTabs<D>(options: CadTabsOptions<D>): CadTabs<D> {
if ((features.remove || (features.close && list.length > 1)) && !tab.fixed) {
const x = document.createElement("button");
x.type = "button";
x.className = "cad-tabs__x";
x.className = "ui-icon-btn ui-icon-btn--ghost cad-tabs__x"; // 아이콘 단추 부품 꼴(553-6)
x.dataset.size = "18";
x.textContent = "×";
x.title = x.ariaLabel = features.remove ? text.remove : text.close;
x.addEventListener("click", (event) => {
@@ -272,7 +273,8 @@ export function createCadTabs<D>(options: CadTabsOptions<D>): CadTabs<D> {
if (features.add) {
const plus = document.createElement("button");
plus.type = "button";
plus.className = "cad-tabs__add";
plus.className = "ui-icon-btn ui-icon-btn--ghost cad-tabs__add";
plus.dataset.size = "22";
plus.textContent = "+";
plus.title = plus.ariaLabel = text.add;
plus.disabled = busy;
+2 -2
View File
@@ -18,13 +18,13 @@
}
.ui-sheet__toolbar .ui-btn.is-on {
background: var(--color-mist-violet);
background: var(--color-accent-soft);
}
.ui-sheet__hint {
margin: 0;
color: var(--color-text-secondary); /* muted 는 작은 글에 대비 3:1 — 모자람 */
font-size: 0.8rem;
font-size: var(--text-caption);
}
.ui-sheet__scroll {