Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1

This commit is contained in:
2026-10-26 22:39:23 +09:00
79 changed files with 1270 additions and 1676 deletions
+9 -43
View File
@@ -1,10 +1,11 @@
/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */
/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 = 부품 보조 탭 · 휠 방향 = 부품 라디오 · CAD 별칭 표 = 부품 표 */
.app-settings .ui-text[hidden] {
display: none; /* 부품 보조 글은 display:flex 라 hidden 을 이김 */
}
.app-settings__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.app-settings__radios {
@@ -13,25 +14,13 @@
gap: var(--spacing-16);
}
.app-settings__radios > label {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
.app-settings__radios > .ui-checkbox {
min-height: 32px;
cursor: pointer;
}
.app-settings__table {
width: 100%;
border-collapse: collapse;
}
.app-settings__table th,
.app-settings__table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
/* 표 머리는 창 몸통을 따라 붙음 — 부품 표 겉틀의 가로 스크롤은 풂 */
.app-settings .ui-table-wrap {
overflow: visible;
}
.app-settings__table thead th {
@@ -40,18 +29,6 @@
background: var(--color-surface-raised);
}
.app-settings__table input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.app-settings__error {
color: var(--color-danger);
}
.app-settings__error:empty {
display: none;
}
.app-settings__rules {
margin: 0 0 var(--spacing-8);
padding: 0;
@@ -77,14 +54,3 @@
.app-settings__keys:only-child {
grid-column: 1 / -1;
}
.app-settings__keys > kbd {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.85em;
line-height: 1.7;
}
+20 -25
View File
@@ -7,12 +7,15 @@
* - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askLeave } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { writeState } from "./b_page_state";
import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs";
import { fillTabs } from "./b_page_tabs";
import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad";
import "./app_settings.css";
@@ -66,7 +69,7 @@ function ruleList(keys: LocaleKey[]): HTMLElement {
children: [
el("span", {
className: "app-settings__keys",
children: input.split(" · ").map((text) => el("kbd", { text })),
children: input.split(" · ").map((text) => createBadge(text, "neutral")),
}),
...(at < 0 ? [] : [el("span", { text: line.slice(at + 3) })]),
],
@@ -81,12 +84,14 @@ function mouseTab(draft: InputPrefs): HTMLElement {
attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") },
});
for (const [mode, key] of WHEEL) {
const input = el("input", {
attrs: { type: "radio", name: "app-settings-wheel", value: mode },
});
input.checked = draft.wheelZoom === mode;
input.addEventListener("change", () => (draft.wheelZoom = mode));
group.append(el("label", { children: [input, L(key)] }));
group.append(
createCheckbox(L(key), {
radio: "app-settings-wheel",
value: mode,
checked: draft.wheelZoom === mode,
onChange: () => (draft.wheelZoom = mode),
}).root,
);
}
return el("div", {
children: [
@@ -114,25 +119,15 @@ export function openAppSettings(): void {
cad: cad.root,
keys: keysTab(),
};
const tabs = el("div", {
className: "app-settings__tabs ui-subtabs",
attrs: { role: "tablist" },
});
const tabs = el("div", { className: "app-settings__tabs" });
const show = (id: TabId): void => {
tabs.replaceChildren(
...TABS.map(([tab, key]) => {
const button = createButton({
label: L(key),
variant: tab === id ? "filled" : "ghost",
size: "sm",
onClick: () => show(tab),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(tab === id));
button.dataset.settingsTab = tab;
return button;
}),
);
fillTabs(tabs, {
tabs: TABS.map(([tab, key]) => ({ key: tab, label: L(key) })),
value: id,
kind: "pill",
data: "settingsTab",
onChange: (tab) => show(tab as TabId),
});
for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id;
};
+17 -15
View File
@@ -8,7 +8,10 @@
* ========================================================================== */
import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json";
import { setUiSize } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createTable } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import { t as L } from "@ui/ui_template_locale";
export interface CadCommandRow {
@@ -67,13 +70,15 @@ export function buildCadAliasTab(aliases: Record<string, string[]>): {
root: HTMLElement;
mark: (errors: Record<string, AliasError>) => void;
} {
const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } });
const message = createText("", "error");
message.hidden = true;
const inputs = new Map<string, HTMLInputElement>();
const rows = CAD_COMMANDS.map((command) => {
const input = el("input", {
className: "ui-input ui-input--sm",
className: "ui-input",
attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` },
});
setUiSize(input, 24);
input.value = (aliases[command.id] ?? []).join(", ");
input.dataset.cadCommand = command.id;
input.addEventListener("input", () => {
@@ -82,25 +87,21 @@ export function buildCadAliasTab(aliases: Record<string, string[]>): {
else delete aliases[command.id];
});
inputs.set(command.id, input);
const cells = [command.id, command.label, command.aliases.join(", ")].map((text) =>
el("td", { text }),
);
return el("tr", { children: [...cells, el("td", { children: [input] })] });
return [command.id, command.label, command.aliases.join(", "), input];
});
const head = el("tr", {
children: (
const table = createTable(
(
[
"App_Settings_Cad_Command",
"App_Settings_Cad_Label",
"App_Settings_Cad_Default",
"App_Settings_Cad_Mine",
] as const
).map((key) => el("th", { text: L(key) })),
});
const table = el("table", {
className: "app-settings__table",
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
});
).map((key) => ({ label: L(key) })),
rows,
{ density: "data" },
);
table.classList.add("app-settings__table");
const mark = (errors: Record<string, AliasError>): void => {
for (const [id, input] of inputs) {
const error = errors[id];
@@ -109,7 +110,8 @@ export function buildCadAliasTab(aliases: Record<string, string[]>): {
input.title = error ? L(ERROR_TEXT[error]) : "";
}
const kinds = [...new Set(Object.values(errors))];
message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · ");
message.lastChild!.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · ");
message.hidden = !kinds.length;
};
const root = el("div", {
children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table],
+14 -28
View File
@@ -9,6 +9,7 @@
import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import { createSegment } from "@ui/ui_template_segment";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated, setLeaveGuard, takeLeaveGuard } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
@@ -246,35 +247,23 @@ function buildTouchModeSwitch(): HTMLElement {
box.className = "app-drawer__touch";
const label = document.createElement("span");
label.textContent = L("App_TouchMode");
const group = document.createElement("div");
group.className = "app-drawer__row";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", L("App_TouchMode"));
const modes: [TouchMode, keyof typeof ui_locales][] = [
["auto", "App_TouchMode_Auto"],
["on", "App_TouchMode_On"],
["off", "App_TouchMode_Off"],
];
const buttons = modes.map(([mode, key]) => {
const b = createButton({
label: L(key),
variant: "pill",
onClick: () => {
setTouchMode(mode);
mark();
},
});
b.setAttribute("role", "radio");
b.dataset.touchMode = mode;
return b;
// 부품 「분할 고르기」(36) — 칸마다 data-touch-mode
const group = createSegment({
options: modes.map(([mode, key]) => ({ value: mode, label: L(key) })),
value: readTouchMode(),
size: 36,
ariaLabel: L("App_TouchMode"),
onChange: (mode) => setTouchMode(mode as TouchMode),
});
const mark = (): void => {
const now = readTouchMode();
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
};
mark();
group.append(...buttons);
box.append(label, group);
group.root.querySelectorAll<HTMLElement>("[data-value]").forEach((b) => {
b.dataset.touchMode = b.dataset.value;
});
box.append(label, group.root);
return box;
}
@@ -522,11 +511,8 @@ html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .ap
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.app-drawer__touch .app-drawer__row { padding-inline: 0; }
.app-drawer__touch [aria-checked="true"] {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.app-drawer__touch .ui-segment { display: flex; }
.app-drawer__touch .ui-segment__item { flex: 1 1 0; min-width: 0; }
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
html[data-size="compact"] .app-actions { display: none; }
+2 -1
View File
@@ -105,7 +105,8 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b06-design__rockb-btn is-reset";
reset.className = "ui-icon-btn ui-icon-btn--ghost b06-design__rockb-btn is-reset"; // 부품 아이콘 단추 22
reset.setAttribute("data-size", "22");
reset.textContent = "↺";
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
reset.disabled = sameRatio(ratio, standard);
+35 -32
View File
@@ -14,7 +14,10 @@
* 병합된다.
* ========================================================================== */
import { createButton, setUiSize } from "@ui/ui_template_button";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createToggle } from "@ui/ui_template_toggle";
import type {
CrossSection,
DitchSide,
@@ -156,25 +159,19 @@ function segment<T extends string>(
legendEl.textContent = legend;
wrap.append(legendEl);
}
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
for (const [value, labelKey] of options) {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__btn${value === selected ? " b06-design__btn--active" : ""}`;
button.textContent = L(labelKey);
button.setAttribute("aria-pressed", value === selected ? "true" : "false");
// 선행 조건 미충족 시 비활성(상시 노출하되 클릭 불가·사유 툴팁) — E-6.
button.disabled = disabled;
if (disabled && reason) button.title = reason;
else button.addEventListener("click", () => onPick(value));
group.append(button);
}
// 부품 분할 고르기 20(553-2) — 선행 조건 미충족 시 비활성(클릭 불가·사유 툴팁) — E-6.
const group = createSegment({
options: options.map(([value, labelKey]) => ({ value, label: L(labelKey), disabled })),
value: selected ?? "",
size: 20,
onChange: (value) => onPick(value as T),
}).root;
if (disabled && reason) group.title = reason;
wrap.append(group);
return wrap;
}
/** 온/오프 토글 하나(포장·2단계 경사 공용). 세그먼트와 같은 컨테이너/버튼 스타일을 쓴다. */
/** 온/오프 토글 하나(포장·2단계 경사 공용) — 부품 켜기 칩 20(553-2). */
function toggle(
legend: string,
on: boolean,
@@ -192,18 +189,16 @@ function toggle(
legendEl.textContent = legend;
wrap.append(legendEl);
}
const buttons = document.createElement("div");
buttons.className = "b06-design__seg-buttons";
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__btn${on ? " b06-design__btn--active" : ""}`;
button.textContent = on ? onLabel : offLabel;
button.setAttribute("aria-pressed", on ? "true" : "false");
button.disabled = disabled;
const button = createToggle({
kind: "chip",
label: on ? onLabel : offLabel,
checked: on,
size: 20,
disabled,
onChange: onToggle,
}).root;
if (disabled && reason) button.title = reason;
else button.addEventListener("click", onToggle);
buttons.append(button);
wrap.append(buttons);
wrap.append(button);
return { wrap, button };
}
@@ -375,18 +370,26 @@ export function buildDesignControls(
const more = document.createElement("details");
more.className = "b06-design__more";
const moreSummary = document.createElement("summary");
moreSummary.className = "b06-design__more-summary";
// 부품 아이콘 단추(선) 22 꼴 — 펼침 = 눌림(aria-pressed)
moreSummary.className = "ui-icon-btn ui-icon-btn--outline b06-design__more-summary";
setUiSize(moreSummary, 22);
moreSummary.textContent = "⋯";
moreSummary.title = L("B06_Design_More");
moreSummary.setAttribute("aria-pressed", "false");
more.addEventListener("toggle", () =>
moreSummary.setAttribute("aria-pressed", String(more.open)),
);
const morePanel = document.createElement("div");
morePanel.className = "b06-design__more-panel";
more.append(moreSummary, morePanel);
// [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임
const detail = document.createElement("button");
detail.type = "button";
detail.className = "b06-design__btn b06-design__detail";
detail.textContent = L("B06_Design_Detail");
detail.addEventListener("click", () => openCrossDetail(section));
const detail = createButton({
label: L("B06_Design_Detail"),
variant: "ghost",
size: 20,
onClick: () => openCrossDetail(section),
});
detail.classList.add("b06-design__detail");
bar.append(slopeDirSeg, ...moveable, more, detail);
refreshCrossDetail(section);
@@ -87,7 +87,9 @@ export function buildLineCell(): LineCell {
const buttons = SLOTS.map(([slot, text]) => {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__rockb-btn ${CELL_BTN}${slot}`;
// 부품 아이콘 단추 22(553-2) — 클래스 · data-size 계약(시험 흉내 DOM 이 @ui 를 안 풂)
button.className = `ui-icon-btn ui-icon-btn--ghost b06-design__rockb-btn ${CELL_BTN}${slot}`;
button.setAttribute("data-size", "22");
if (slot === "reset") button.classList.add("is-reset");
button.textContent = text;
// 길게 누름 — 누르면 즉시 1회 · 반복 예약 · 이어지는 click 은 삼킴
@@ -414,29 +414,7 @@
stroke-width: 8;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
width: 22px;
height: 22px;
padding: 0;
font-size: 0.7rem;
line-height: 1;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
.b06-design__rockb-btn:first-child {
border-left: none;
}
.b06-design__rockb-btn:hover {
color: var(--color-text);
background: var(--color-surface-raised);
}
/* 암 경계선 상/하/리셋 제어 — 단추 = 부품 아이콘 단추 22(553-2) · ↺ 만 경고 글색 */
.b06-design__rockb-btn.is-reset {
color: var(--color-warning);
}
@@ -206,19 +206,8 @@
margin-left: auto;
}
/* ⋯ = 부품 아이콘 단추(선) 22 — 펼침 표시만 지움 */
.b06-design__more-summary {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
list-style: none;
}
@@ -226,12 +215,6 @@
display: none;
}
.b06-design__more[open] .b06-design__more-summary {
color: var(--color-surface);
background: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
}
.b06-design__more-panel {
position: absolute;
right: 0;
@@ -254,7 +237,7 @@
}
.b06-design__seg-legend {
font-size: 0.72rem;
font-size: var(--text-caption);
color: var(--color-text-secondary);
white-space: nowrap;
}
@@ -271,31 +254,11 @@
overflow: hidden;
}
.b06-design__btn {
padding: 3px 8px;
font-size: 0.72rem;
color: var(--color-text-secondary);
background: var(--color-surface);
border: none;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
.b06-design__btn:first-child {
border-left: none;
}
.b06-design__btn--active {
color: var(--color-surface);
background: var(--color-royal-amethyst);
font-weight: var(--font-weight-medium);
}
.b06-design__areas {
display: inline-flex;
gap: var(--spacing-8);
margin-left: auto;
font-size: 0.72rem;
font-size: var(--text-caption);
font-family: var(--font-mono);
}
@@ -329,13 +292,11 @@
.b06-cross-card__linecell .b06-design__rockb-btn {
flex: 0 0 auto;
border-left: none;
}
.b06-cross-card__linecell
.b06-design__rockb-btn:not([hidden])
~ .b06-design__rockb-btn:not([hidden]) {
border-left: 1px solid var(--color-border);
/* 부품 아이콘 단추의 display 가 hidden 을 덮음 — 상태에 없는 칸은 숨김 */
.b06-design__rockb-btn[hidden] {
display: none;
}
/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */
@@ -349,7 +310,7 @@
.b06-design__cutslope-input {
width: 44px;
padding: 0 2px;
font-size: 0.72rem;
font-size: var(--text-caption);
font-family: var(--font-mono);
text-align: right;
color: var(--color-text);
@@ -367,7 +328,7 @@
.b06-design__cutslope-unit {
padding: 0 2px 0 1px;
font-size: 0.72rem;
font-size: var(--text-caption);
color: var(--color-text-secondary);
align-self: center;
}
+16 -63
View File
@@ -27,9 +27,8 @@
--cad-text-dim: var(--color-text-secondary);
--cad-text-muted: var(--color-text-muted);
--cad-accent: var(--color-accent);
/* 마우스 바탕 = 부품 상자와 같은 값 · 켜짐 바탕은 --color-accent-soft */
--cad-hover: var(--color-hover);
/* 켜짐 바탕 = 앱 켜기 꼴(553-6) */
--cad-on: var(--color-accent-soft);
--cad-canvas: var(--draw-canvas);
}
@@ -113,21 +112,12 @@ body > canvas[data-id="canvas"] {
flex: 1;
min-width: 0;
}
/* 접기 단추 = 아이콘 단추 부품(단 20) · 여기는 자리만 */
.cad-ribbon-tabs__collapse {
width: 24px;
height: 20px;
align-self: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-ribbon-tabs__collapse:hover {
background: var(--cad-hover);
}
.cad-ribbon-tabs button {
/* 리본 탭 = CAD 고유 꼴 그대로(토큰만) */
.cad-ribbon-tabs > button:not(.ui-icon-btn) {
padding: 0 14px;
border: 1px solid transparent;
border-bottom: 0;
@@ -136,10 +126,10 @@ body > canvas[data-id="canvas"] {
color: var(--cad-text-dim);
font-size: 12px;
}
.cad-ribbon-tabs button:hover {
.cad-ribbon-tabs > button:not(.ui-icon-btn):hover {
background: var(--cad-hover);
}
.cad-ribbon-tabs button[data-active="true"] {
.cad-ribbon-tabs > button[data-active="true"] {
background: var(--cad-chrome);
border-color: var(--cad-line);
color: var(--color-text);
@@ -187,53 +177,25 @@ body > canvas[data-id="canvas"] {
.cad-ribbon-group__label[data-expandable="true"]:hover {
color: var(--cad-accent);
}
/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리만 */
.cad-ribbon-overflow {
/* 리본 패널 줄이 가로 스크롤 상자라 그 안에서는 잘린다 — 화면 좌표로 띄운다 */
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 170px;
max-height: 72vh;
padding: 4px;
overflow-y: auto;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
}
.cad-ribbon-overflow button {
.cad-ribbon-overflow > .ui-ctx-menu__item {
display: flex;
gap: 8px;
align-items: center;
padding: 5px 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
font-size: 11px;
text-align: left;
}
.cad-ribbon-overflow button:hover {
background: var(--cad-hover);
}
/* ↶ ↷ = 아이콘 단추 부품(단 20) · 여기는 자리만 */
.cad-quick-access {
display: flex;
gap: 2px;
align-self: center;
}
.cad-ribbon-tabs .cad-quick-access button {
width: 20px;
height: 20px;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
line-height: 1;
font-size: 13px;
}
.cad-quick-access button:hover {
border-color: var(--cad-line);
background: var(--cad-hover);
}
.cad-ribbon-props {
display: flex;
flex-direction: column;
@@ -254,6 +216,7 @@ body > canvas[data-id="canvas"] {
width: 52px;
text-align: left;
}
/* 리본 안 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 18 · 둥글기 4 */
.cad-prop select,
.cad-prop input[type="number"] {
height: 18px;
@@ -285,7 +248,6 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
.cad-ribbon-props .cad-tool[data-size="small"] {
height: 22px;
min-width: 0;
border-radius: 4px;
}
.cad-tool {
display: flex;
@@ -296,7 +258,7 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
min-width: 48px;
padding: 3px 6px;
border: 1px solid transparent;
border-radius: var(--radius-buttons);
border-radius: 3px;
background: transparent;
font-size: 10px;
white-space: nowrap;
@@ -331,7 +293,7 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
}
.cad-tool[data-active="true"] {
border-color: var(--cad-accent);
background: var(--cad-on);
background: var(--color-accent-soft);
box-shadow: inset 0 -2px var(--cad-accent);
}
.cad-tool:disabled {
@@ -376,23 +338,14 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans
background: var(--cad-chrome-raised);
color: var(--cad-text-dim);
}
/* 꼴 = 탭 부품(`ui-tabs`) · 여기는 칸 둘이 폭을 나누는 배치만 */
.cad-inspector-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
height: 36px;
border-bottom: 1px solid var(--cad-line);
gap: 0;
}
.cad-inspector-tabs button {
border: 0;
border-bottom: 2px solid transparent;
background: var(--cad-chrome-sunken);
color: var(--cad-text-dim);
font-size: 12px;
}
.cad-inspector-tabs button[data-active="true"] {
border-bottom-color: var(--cad-accent);
background: var(--cad-chrome-raised);
color: var(--color-text);
.cad-inspector-tabs > .ui-tabs__tab {
justify-content: center;
}
.cad-properties {
padding: 14px 12px;
@@ -74,7 +74,6 @@ export interface Layer {
export enum LOCAL_STORAGE_KEY {
DRAWING = 'OPEN_WEB_CAD__DRAWING',
DROPDOWN = 'OPEN_WEB_CAD__DROPDOWN',
}
export interface StartAndEndpointEntity {
+30 -182
View File
@@ -14,22 +14,22 @@
padding-right: 2px;
min-width: 0;
}
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
/* 도면층 버튼 — 목록 줄 부품(`ui-sidebar-row`) 꼴 · 상태는 Button이 붙이는 data-* 속성으로 가른다. */
.cad-layer-manager button[data-type="regular"] {
background: var(--cad-chrome-sunken);
color: var(--cad-accent);
background: transparent;
color: var(--color-text-body);
}
.cad-layer-manager button[data-type="transparent"] {
background: transparent;
color: var(--cad-accent);
color: var(--color-text-secondary);
}
.cad-layer-manager button:hover {
background: var(--cad-hover);
background: var(--color-hover);
color: var(--color-text);
}
.cad-layer-manager button[data-active="true"] {
background: var(--color-primary);
color: var(--color-primary-text);
background: var(--color-accent-soft);
color: var(--color-accent);
}
.cad-view-controls {
@@ -87,21 +87,7 @@ body.cad-file-dragging::after {
pointer-events: none;
}
.cad-view-controls button {
width: 34px;
height: 32px;
border: 0;
border-right: 1px solid var(--cad-line);
background: transparent;
font-size: 17px;
}
.cad-view-controls button:hover {
background: var(--cad-hover);
}
.cad-view-controls__fit {
color: var(--cad-accent);
font-size: 15px;
}
/* 단추 = 아이콘 단추 부품(단 32) · 여기는 배율 글만 */
.cad-view-controls span {
min-width: 48px;
color: var(--cad-text-dim);
@@ -133,30 +119,16 @@ body.cad-file-dragging::after {
color: var(--cad-text-muted);
font: 11px Consolas, monospace;
}
/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리와 명령 글꼴만 */
.cad-command-suggestions {
position: absolute;
z-index: 6;
bottom: 100%;
left: 10px;
display: flex;
flex-direction: column;
min-width: 220px;
padding: 4px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
}
.cad-command-suggestions button {
padding: 4px 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
font: 11px Consolas, monospace;
text-align: left;
}
.cad-command-suggestions button:hover {
background: var(--cad-hover);
.cad-command-suggestions > .ui-ctx-menu__item {
font: 12px Consolas, monospace;
}
.cad-prop--wide select {
min-width: 132px;
@@ -180,12 +152,13 @@ body.cad-file-dragging::after {
font-size: 11px;
font-weight: 600;
}
/* 명령 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 28 */
.cad-command-area input {
flex: 1;
height: 28px;
padding: 0 8px;
border: 1px solid var(--cad-line);
border-radius: var(--radius-inputs);
border-radius: var(--radius-buttons);
outline: none;
background: var(--cad-chrome-sunken);
color: var(--cad-text);
@@ -210,23 +183,7 @@ body.cad-file-dragging::after {
background: var(--cad-chrome);
border-top: 1px solid var(--cad-line);
}
.cad-statusbar button {
height: 22px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--cad-text-dim);
font-size: 10px;
}
.cad-statusbar button:hover {
background: var(--cad-hover);
}
.cad-statusbar button[data-active="true"] {
border-color: var(--cad-accent);
background: var(--color-primary);
color: var(--color-primary-text);
}
/* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
.cad-statusbar__hint {
margin-left: auto;
color: var(--cad-text-muted);
@@ -261,6 +218,7 @@ body.cad-file-dragging::after {
font-size: 11px;
color: var(--cad-text-dim);
}
/* 칸 = 크기 단 24(둥글기 6) · 토큰만 */
.cad-properties-editor select,
.cad-properties-editor input {
height: 24px;
@@ -292,59 +250,19 @@ body.cad-file-dragging::after {
color: var(--cad-text);
text-overflow: ellipsis;
}
.cad-properties__empty {
margin: 0;
color: var(--cad-text-muted);
font-size: 11px;
}
/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */
.cad-quick-properties {
position: fixed;
z-index: 5;
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
right: 14px;
width: 240px;
padding: 8px 10px 10px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
}
.cad-quick-properties__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-quick-properties__header button {
border: 0;
background: transparent;
color: var(--cad-text-muted);
font-size: 12px;
}
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
/* 빠른 특성 · 블록 라이브러리 — 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭만 */
.cad-quick-properties,
.cad-block-library {
position: fixed;
z-index: 5;
top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px);
right: 14px;
width: 240px;
padding: 8px 10px 10px;
background: var(--cad-chrome-raised);
border: 1px solid var(--cad-line);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
}
.cad-block-library__empty {
margin: 0;
color: var(--cad-text-muted);
font-size: 11px;
line-height: 1.5;
}
/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */
.cad-block-library__list {
max-height: 220px;
margin: 0;
@@ -357,41 +275,26 @@ body.cad-file-dragging::after {
align-items: center;
gap: 4px;
}
.cad-block-library__list li button {
border: 0;
background: transparent;
color: var(--cad-text);
font-size: 12px;
}
/* 블록 줄 = 목록 줄 부품(`ui-sidebar-row`) · 지우기 = 아이콘 단추 부품(단 20) · 여기는 줄 폭만 */
.cad-block-library__name {
flex: 1;
min-width: 0;
overflow: hidden;
padding: 3px 4px;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.cad-block-library__name:hover {
background: var(--cad-chrome-sunken);
}
/* 단추 = 단추 부품(보조 · 단 24) · 여기는 둘이 폭을 나눔만 */
.cad-block-library__actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.cad-block-library__actions button {
.cad-block-library__actions > .ui-btn {
flex: 1;
height: 24px;
padding: 0;
border: 1px solid var(--cad-line);
border-radius: 6px;
background: transparent;
color: var(--cad-text-dim);
font-size: 11px;
}
/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로
부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */
부모 사이드바에서 캐드 안으로 옮김). 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭 · 줄 배치만. */
.cad-frame-tokens {
position: fixed;
/* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */
@@ -399,36 +302,13 @@ body.cad-file-dragging::after {
bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px);
z-index: 3;
max-height: 46vh;
overflow-y: auto;
width: 236px;
}
.cad-frame-tokens__body {
display: flex;
flex-direction: column;
gap: 6px;
width: 236px;
padding: 8px;
border: 1px solid var(--cad-line);
border-radius: var(--radius-cards);
background: var(--cad-chrome-raised);
box-shadow: var(--shadow-lg);
color: var(--cad-text);
}
.cad-frame-tokens__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
color: var(--cad-text-dim);
font-size: 11px;
line-height: 1.4;
}
.cad-frame-tokens__fold {
flex: none;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: transparent;
color: var(--cad-text);
cursor: pointer;
}
.cad-frame-tokens__group {
@@ -440,41 +320,9 @@ body.cad-file-dragging::after {
.cad-frame-tokens__title {
width: 100%;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-frame-tokens__button {
height: 22px;
padding: 0 8px;
border: 1px solid var(--cad-line);
border-radius: 4px;
background: var(--cad-chrome);
color: var(--cad-text);
font-size: 11px;
cursor: pointer;
}
.cad-frame-tokens__button:hover {
background: var(--cad-hover);
}
/* 자리표 칸 크기 입력 (2026-09-06) */
.cad-frame-tokens__size {
display: flex;
align-items: center;
gap: 4px;
color: var(--cad-text-dim);
font-size: 11px;
}
.cad-frame-tokens__size input {
/* 자리표 칸 크기 입력 (2026-09-06) — 칸 = 입력 부품(단 24) */
.cad-frame-tokens__size > .ui-input {
width: 64px;
height: 24px;
padding: 0 4px;
border: 1px solid var(--cad-line);
border-radius: 6px;
background: var(--cad-chrome);
color: var(--cad-text);
font-size: 11px;
}
@@ -1,11 +1,11 @@
/** 삽입 탭 명령 (조사표 4절 중 구현분) */
import { toast } from 'react-toastify';
import { askText } from '../../../../ui_template/ui_template_modal';
import { saveBlockFromEntities } from '../blocks/block-library';
import { setBlockLibraryVisible } from '../components/ui-state';
import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file';
import { pickFile } from '../helpers/pick-file';
import { importDrawingFile } from '../integration/dxf-clip-paste';
import { askText } from '../integration/ui-kit';
import { getActiveToolActor, getSelectedEntities } from '../state';
import { Tool } from '../tools';
import { imageImportToolStateMachine } from '../tools/image-import-tool';
@@ -40,14 +40,13 @@ export const INSERT_COMMANDS: CadCommand[] = [
needsSelection: true,
run: () => {
const entities = getSelectedEntities();
// 글 묻기 = 부품 확인창(553-6 · window.prompt 대신)
void askText({ message: '블록 이름', okLabel: '저장' }).then((raw) => {
const name = raw?.trim();
// 글 묻기 = 확인창 부품(window.prompt 대신 · 553-6)
void askText({ message: '블록 이름', okLabel: '저장' }).then(async (text) => {
const name = text?.trim();
if (!name) return;
void saveBlockFromEntities(name, entities).then(() => {
toast.success(`블록 '${name}' 저장 (객체 ${entities.length}개)`);
setBlockLibraryVisible(true);
});
await saveBlockFromEntities(name, entities);
toast.success(`블록 '${name}' 저장 (객체 ${entities.length}개)`);
setBlockLibraryVisible(true);
});
return '블록 이름 입력';
},
@@ -1,6 +1,5 @@
/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) */
/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) · 떠 있는 판 부품 */
import type { FC } from 'react';
import { showConfirmDialog } from '../../../../ui_template/ui_template_modal';
import {
deleteBlock,
exportBlocksToFile,
@@ -8,6 +7,7 @@ import {
importBlocksFromFile,
} from '../blocks/block-library';
import { pickFile } from '../helpers/pick-file';
import { askConfirm } from '../integration/ui-kit';
import { startBlockInsert } from '../tools/insert-block-tool';
import { isBlockLibraryVisible, setBlockLibraryVisible } from './ui-state';
@@ -17,58 +17,83 @@ export const BlockLibraryPanel: FC = () => {
const blocks = getBlocks();
return (
<div className="cad-block-library controls">
<div className="cad-quick-properties__header">
<span>블록 라이브러리</span>
<button type="button" onClick={() => setBlockLibraryVisible(false)} title="닫기">
<div className="ui-float-panel cad-block-library controls">
<div className="ui-float-panel__head">
<span className="ui-float-panel__title">블록 라이브러리</span>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
onClick={() => setBlockLibraryVisible(false)}
title="닫기"
aria-label="닫기"
>
✕
</button>
</div>
<div className="ui-float-panel__body">
{blocks.length === 0 ? (
<div className="ui-empty ui-empty--small">
<p className="ui-empty__text">객체를 선택하고 BLOCK 명령으로 저장하십시오.</p>
</div>
) : (
<ul className="cad-block-library__list">
{blocks.map((block) => (
<li key={block.name}>
<button
type="button"
className="ui-sidebar-row cad-block-library__name"
title={`객체 ${block.entities.length}개 — 클릭하면 삽입`}
onClick={() => {
setBlockLibraryVisible(false);
void startBlockInsert(block);
}}
>
{block.name}
</button>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="20"
title="삭제"
aria-label="삭제"
onClick={async () => {
if (
await askConfirm({
message: `블록 '${block.name}'을 삭제하시겠습니까?`,
okLabel: '삭제',
danger: true,
})
) {
deleteBlock(block.name);
}
}}
>
✕
</button>
</li>
))}
</ul>
)}
{blocks.length === 0 ? (
<p className="cad-block-library__empty">객체를 선택하고 BLOCK 명령으로 저장하십시오.</p>
) : (
<ul className="cad-block-library__list">
{blocks.map((block) => (
<li key={block.name}>
<button
type="button"
className="cad-block-library__name"
title={`객체 ${block.entities.length}개 — 클릭하면 삽입`}
onClick={() => {
setBlockLibraryVisible(false);
void startBlockInsert(block);
}}
>
{block.name}
</button>
<button
type="button"
title="삭제"
onClick={() => {
// 위험 확인 = 부품 확인창(553-6 · window.confirm 대신)
void showConfirmDialog(`블록 '${block.name}'을 삭제하시겠습니까?`, '삭제').then(
(ok) => ok && deleteBlock(block.name),
);
}}
>
✕
</button>
</li>
))}
</ul>
)}
<div className="cad-block-library__actions">
<button type="button" onClick={() => exportBlocksToFile()}>
파일로 내보내기
</button>
<button
type="button"
onClick={() => pickFile('application/json', (file) => importBlocksFromFile(file))}
>
파일에서 가져오기
</button>
<div className="cad-block-library__actions">
<button
type="button"
className="ui-btn ui-btn--ghost"
data-size="24"
onClick={() => exportBlocksToFile()}
>
파일로 내보내기
</button>
<button
type="button"
className="ui-btn ui-btn--ghost"
data-size="24"
onClick={() => pickFile('application/json', (file) => importBlocksFromFile(file))}
>
파일에서 가져오기
</button>
</div>
</div>
</div>
);
@@ -37,9 +37,9 @@ export const Button: FC<ButtonProps> = ({
left = null,
right = null,
}) => {
// 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만.
// 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만 · 높이 = 크기 단 42(글 14 · 둥글기 8)
const classParts = [
'font-semibold py-4 h-[42px] flex flex-row justify-start w-full items-center',
'font-semibold h-[42px] text-sm rounded-lg flex flex-row justify-start w-full items-center',
size === 'regular' ? 'pl-2 pr-2 gap-2' : '',
size === 'small' ? 'pl-2 pr-2 gap-0' : '',
className || '',
@@ -16,7 +16,10 @@ export const CommandLine: FC = () => {
const history = getCommandHistory();
const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.';
const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]);
const suggestions = useMemo(
() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8),
[text, pick]
);
const submit = (event: FormEvent) => {
event.preventDefault();
@@ -102,10 +105,11 @@ export const CommandLine: FC = () => {
<span className="cad-command-prompt">{instruction}</span>
</form>
{text.trim() && suggestions.length > 0 && (
<div className="cad-command-suggestions">
<div className="ui-ctx-menu cad-command-suggestions">
{suggestions.map((command) => (
<button
type="button"
className="ui-ctx-menu__item"
key={command.id}
onClick={() => {
runCommandInput(command.id);
@@ -139,15 +139,15 @@ export const FramePlaceholderPanel: FC = () => {
if (!visible) return null;
return (
<section className="cad-frame-tokens controls">
<header className="cad-frame-tokens__header">
<span>
{folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'}
</span>
<section className="ui-float-panel cad-frame-tokens controls">
<header className="ui-float-panel__head cad-frame-tokens__header">
<span className="ui-float-panel__title">자리표 놓기</span>
<button
type="button"
className="cad-frame-tokens__fold"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
aria-expanded={!folded}
aria-label={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
title={folded ? '자리표 패널 펴기' : '자리표 패널 접기'}
onClick={() => setFolded(!folded)}
>
@@ -155,58 +155,71 @@ export const FramePlaceholderPanel: FC = () => {
</button>
</header>
{!folded && (
<>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => addTextPlaceholder(token)}
>
{token}
</button>
))}
</div>
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">그림 (칸에 비율 그대로 들어감)</span>
{IMAGE_TOKENS.map((token) => (
<button
key={token}
type="button"
className="cad-frame-tokens__button"
onClick={() => void addImagePlaceholder(token)}
>
{token}
</button>
))}
</div>
{picked && (
<div className="cad-frame-tokens__group">
<span className="cad-frame-tokens__title">고른 자리표 칸 크기 (mm)</span>
<label className="cad-frame-tokens__size">
가로
<input
type="number"
min={1}
value={size.width}
onChange={(event) => applySize(Number(event.target.value), size.height)}
/>
</label>
<label className="cad-frame-tokens__size">
세로
<input
type="number"
min={1}
value={size.height}
onChange={(event) => applySize(size.width, Number(event.target.value))}
/>
</label>
<div className="ui-float-panel__body cad-frame-tokens__body">
<p className="ui-text ui-text--muted">
누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다
</p>
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">글자</span>
{TEXT_TOKENS.map((token) => (
<button
key={token}
type="button"
className="ui-btn ui-btn--ghost"
data-size="22"
onClick={() => addTextPlaceholder(token)}
>
{token}
</button>
))}
</div>
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">
그림 (칸에 비율 그대로 들어감)
</span>
{IMAGE_TOKENS.map((token) => (
<button
key={token}
type="button"
className="ui-btn ui-btn--ghost"
data-size="22"
onClick={() => void addImagePlaceholder(token)}
>
{token}
</button>
))}
</div>
{picked && (
<div className="cad-frame-tokens__group">
<span className="ui-text ui-text--muted cad-frame-tokens__title">
고른 자리표 칸 크기 (mm)
</span>
<label className="ui-text ui-text--muted cad-frame-tokens__size">
가로
<input
type="number"
className="ui-input"
data-size="24"
min={1}
value={size.width}
onChange={(event) => applySize(Number(event.target.value), size.height)}
/>
</label>
<label className="ui-text ui-text--muted cad-frame-tokens__size">
세로
<input
type="number"
className="ui-input"
data-size="24"
min={1}
value={size.height}
onChange={(event) => applySize(size.width, Number(event.target.value))}
/>
</label>
</div>
)}
</div>
)}
</>
)}
</section>
);
};
@@ -27,17 +27,24 @@ export const InspectorPanel: FC<InspectorPanelProps> = ({ collapsed, onToggleCol
</button>
{!collapsed && (
<>
<div className="cad-inspector-tabs">
{/* 탭 부품(밑줄 탭 · 단 36) — 칸 둘이 폭을 나눠 가짐 */}
<div className="ui-tabs cad-inspector-tabs" role="tablist">
<button
type="button"
data-active={tab === 'properties'}
role="tab"
className={`ui-tabs__tab${tab === 'properties' ? ' is-active' : ''}`}
data-size="36"
aria-selected={tab === 'properties'}
onClick={() => openInspector('properties')}
>
특성
</button>
<button
type="button"
data-active={tab === 'layers'}
role="tab"
className={`ui-tabs__tab${tab === 'layers' ? ' is-active' : ''}`}
data-size="36"
aria-selected={tab === 'layers'}
onClick={() => openInspector('layers')}
>
레이어
@@ -18,7 +18,8 @@ interface PropertiesEditorProps {
}
/** 고른 것을 사본으로 바꿈 — 되돌리기 한 칸(488) · 선 특성은 글 뺌 */
const applyToSelection = (mutate: (entity: Entity) => void) => restyle(getSelectedEntities(), mutate);
const applyToSelection = (mutate: (entity: Entity) => void) =>
restyle(getSelectedEntities(), mutate);
const applyToLines = (mutate: (entity: Entity) => void) =>
restyle(styleTargets(getSelectedEntities()), mutate);
@@ -30,7 +31,11 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
const view = selectionLineStyle();
if (!first) {
return <p className="cad-properties__empty">선택된 객체가 없습니다.</p>;
return (
<div className="ui-empty ui-empty--small">
<p className="ui-empty__text">선택된 객체가 없습니다.</p>
</div>
);
}
const points = sampleEntityPoints(first);
@@ -1,4 +1,4 @@
/** 빠른 실행 단추(↶ ↷) — 리본 탭 줄 오른쪽(접기 단추 앞)에 그린다 · 숨김 목록은 DOM 만 둔다 */
/** 빠른 실행 단추(↶ ↷) — 리본 탭 줄 오른쪽(접기 단추 앞)에 그린다 · 아이콘 단추 부품(단 20) · 숨김 목록은 DOM 만 둔다 */
import type { FC } from 'react';
import { getCommandById } from '../commands/registry';
import { runCommand } from '../commands/run-command';
@@ -16,6 +16,9 @@ export const QuickAccessBar: FC = () => {
return (
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="20"
aria-label={command.label}
key={command.id}
hidden={QUICK_ACCESS_HIDDEN.includes(id)}
title={`${command.label} (${command.id})`}
@@ -1,4 +1,4 @@
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) */
/** 빠른 특성 — 선택이 있을 때만 화면 오른쪽 위에 뜨는 간이 특성 상자 (QUICKPROPERTIES) · 떠 있는 판 부품 */
import type { FC } from 'react';
import { PropertiesEditor } from './PropertiesEditor';
import { getSelectedEntities } from '../state';
@@ -8,14 +8,23 @@ export const QuickProperties: FC = () => {
if (!isQuickPropertiesVisible() || getSelectedEntities().length === 0) return null;
return (
<div className="cad-quick-properties controls">
<div className="cad-quick-properties__header">
<span>빠른 특성</span>
<button type="button" onClick={() => setQuickPropertiesVisible(false)} title="닫기">
<div className="ui-float-panel cad-quick-properties controls">
<div className="ui-float-panel__head">
<span className="ui-float-panel__title">빠른 특성</span>
<button
type="button"
className="ui-icon-btn ui-icon-btn--ghost"
data-size="24"
onClick={() => setQuickPropertiesVisible(false)}
title="닫기"
aria-label="닫기"
>
✕
</button>
</div>
<PropertiesEditor compact />
<div className="ui-float-panel__body">
<PropertiesEditor compact />
</div>
</div>
);
};
@@ -1,4 +1,4 @@
/** 상태막대 — AutoCAD 하단 제도 보조 토글 */
/** 상태막대 — AutoCAD 하단 제도 보조 토글 · 켜기 칩 부품(`ui-toggle--chip` · 단 22) */
import type { FC } from 'react';
import {
getAngleStep,
@@ -27,7 +27,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
<footer className="cad-statusbar controls">
<button
type="button"
data-active={snap}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={snap}
title="객체 스냅 (OSNAP)"
onClick={() => setSnapEnabled(!snap)}
>
@@ -35,7 +37,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={ortho}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={ortho}
title="직교 모드 (F8) — 90° 간격 각도 가이드"
onClick={() => setAngleStep(ortho ? 0 : 90)}
>
@@ -43,7 +47,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={polar}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={polar}
title="극좌표 추적 (F10) — 45° 간격 각도 가이드"
onClick={() => setAngleStep(polar ? 0 : 45)}
>
@@ -51,7 +57,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={snapTracking}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={snapTracking}
title="객체 스냅 추적 — 스냅점에 잠시 머물면 그 점에서 정렬선이 뻗는다"
onClick={() => setSnapTrackingEnabled(!snapTracking)}
>
@@ -59,7 +67,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={grid}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={grid}
title="그리드 표시"
onClick={() => setGridEnabled(!grid)}
>
@@ -67,7 +77,9 @@ export const StatusBar: FC<StatusBarProps> = ({ commandLineVisible, onToggleComm
</button>
<button
type="button"
data-active={commandLineVisible}
className="ui-toggle ui-toggle--chip"
data-size="22"
aria-pressed={commandLineVisible}
title="명령행 표시/숨기기"
onClick={onToggleCommandLine}
>
@@ -3,12 +3,10 @@ import type { FC } from 'react';
import { runCommandInput } from '../commands/run-command';
import { redoCommand, undoCommand } from '../inputController/prompt-input';
import { zoomExtents } from '../inputController/zoom-prompt';
import {
getScreenCanvasDrawController,
isDrawingReadOnly,
isHostSave,
isViewOnly,
} from '../state';
import { getScreenCanvasDrawController, isDrawingReadOnly, isHostSave, isViewOnly } from '../state';
/** 아이콘 단추 부품(단 32) */
const ICON_BUTTON = { className: 'ui-icon-btn ui-icon-btn--ghost', 'data-size': '32' } as const;
export const ViewControls: FC = () => {
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
@@ -19,27 +17,52 @@ export const ViewControls: FC = () => {
<div className="cad-view-controls controls">
{showUndo && (
<>
<button type="button" onClick={undoCommand} title="실행 취소 (Ctrl+Z)">
<button
type="button"
{...ICON_BUTTON}
onClick={undoCommand}
title="실행 취소 (Ctrl+Z)"
aria-label="실행 취소"
>
↶
</button>
<button type="button" onClick={redoCommand} title="다시 실행 (Ctrl+Y)">
<button
type="button"
{...ICON_BUTTON}
onClick={redoCommand}
title="다시 실행 (Ctrl+Y)"
aria-label="다시 실행"
>
↷
</button>
</>
)}
<button
type="button"
className="cad-view-controls__fit"
{...ICON_BUTTON}
onClick={() => zoomExtents()}
title="범위 줌 (Z) — 도면 전체를 화면에 맞춘다"
aria-label="범위 줌"
>
⛶
</button>
<button type="button" onClick={() => runCommandInput('ZOOMIN')} title="확대">
<button
type="button"
{...ICON_BUTTON}
onClick={() => runCommandInput('ZOOMIN')}
title="확대"
aria-label="확대"
>
+
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => runCommandInput('ZOOMOUT')} title="축소">
<button
type="button"
{...ICON_BUTTON}
onClick={() => runCommandInput('ZOOMOUT')}
title="축소"
aria-label="축소"
>
-
</button>
</div>
@@ -1,29 +1,5 @@
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */
/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 커서 자리 · 위 겹침만 */
.cad-snap-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.cad-snap-menu > button {
padding: 4px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.cad-snap-menu > button:hover,
.cad-snap-menu > button:focus-visible {
background: var(--color-hover);
}
@@ -55,11 +55,12 @@ export function openSnapMenu(x: number, y: number, onPick: (name: string) => voi
current = { onPick, element: null };
if (typeof document === 'undefined') return;
const menu = document.createElement('div');
menu.className = 'cad-snap-menu controls';
menu.className = 'ui-ctx-menu cad-snap-menu controls'; // 꼴 = 메뉴 부품
menu.setAttribute('role', 'menu');
for (const [name, label] of SNAP_MENU) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'ui-ctx-menu__item';
item.setAttribute('role', 'menuitem');
item.textContent = `${label} (${name})`;
item.addEventListener('click', () => pickSnapMenu(name));
@@ -0,0 +1,18 @@
/**
* 부품 상자(`ui_template/`) CAD 쪽 한 벌(553-6) — CAD 겉도 앱 화면과 같은 부품 꼴을 씀.
* 부품 CSS 를 싣고 단추 · 확인창 기본 꼴(`injectBaseStyles`)을 한 번 넣음 · 확인창 · 글 묻기를 다시 냄.
* React 화면은 부품 클래스(`ui-btn` · `ui-icon-btn` · `ui-toggle--chip` · `ui-tabs` · `ui-float-panel` · `ui-ctx-menu` · `ui-empty` · `ui-text` · `ui-input`)와
* `data-size` 단을 직접 붙임 · 자리 · 폭은 App.css 의 `cad-*` 가 맡음 · 도면 그림 색(`--draw-*`)은 안 건드림.
*/
import '../../../../ui_template/ui_template_tabs.css';
import '../../../../ui_template/ui_template_toggle.css';
import '../../../../ui_template/ui_template_float_panel.css';
import '../../../../ui_template/ui_template_context_menu.css';
import '../../../../ui_template/ui_template_empty.css';
import '../../../../ui_template/ui_template_input.css';
import '../../../../ui_template/ui_template_text.css';
import { injectBaseStyles } from '../../../../ui_template/ui_template_elements_styles.ts';
export { askConfirm, askText } from '../../../../ui_template/ui_template_modal.ts';
injectBaseStyles();
+2 -2
View File
@@ -3,11 +3,12 @@ import { Point } from '@flatten-js/core';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Actor, type MachineSnapshot } from 'xstate';
// 부품 상자 CSS 를 앱 CSS 보다 먼저 실음 — 자리 · 폭은 뒤에 오는 App.css 의 `cad-*` 가 덮음
import './integration/ui-kit.ts';
import { SNAP_POINT_DISTANCE } from './App.consts';
import App from './App.tsx';
import { TOOL_STATE_MACHINES } from './commands/registry';
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
import { injectBaseStyles } from '../../../ui_template/ui_template_elements_styles';
import { registerAutoSave } from './helpers/autosave.ts';
import { registerCadDebugHook } from './helpers/debug-hook.ts';
import { draw } from './helpers/draw';
@@ -48,7 +49,6 @@ import { ActorEvent, type DrawEvent } from './tools/tool.types';
// 호스트 앱의 화이트/블랙 모드를 먼저 붙인 뒤 렌더한다.
syncThemeFromHost();
injectBaseStyles(); // 부품 단추 · 확인창 꼴(553-6 · 앱과 같은 한 벌)
// 부모가 `?bare` 로 띄우면 첫 그림부터 캔버스만 — 도면을 넘길 때 다시 정함(522-2)
if (new URLSearchParams(window.location.search).has('bare')) setBare(true);
@@ -129,10 +129,14 @@ const Panel: FC<{
{overflowCommands.length ? ' ▾' : ''}
</button>
{overflowAt && overflowCommands.length > 0 && (
<div className="cad-ribbon-overflow" style={{ top: overflowAt.top, left: overflowAt.left }}>
<div
className="ui-ctx-menu cad-ribbon-overflow"
style={{ top: overflowAt.top, left: overflowAt.left }}
>
{overflowCommands.map((command) => (
<button
type="button"
className="ui-ctx-menu__item"
key={command.id}
title={command.hint}
onClick={() => {
@@ -228,7 +232,9 @@ export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) =
<QuickAccessBar />
<button
type="button"
className="cad-ribbon-tabs__collapse"
className="ui-icon-btn ui-icon-btn--ghost cad-ribbon-tabs__collapse"
data-size="20"
aria-label={collapsed ? '리본 펼치기' : '리본 접기'}
onClick={() => {
setPeek(false);
setRibbonCollapsed(!collapsed);
+2 -37
View File
@@ -33,7 +33,7 @@
}
/* M02 에 없는 조합 안내 — 빨간 글 */
.b08-sheets__combo > .ui-general-block__empty {
.b08-sheets__combo > .ui-text {
color: var(--color-danger);
}
@@ -75,31 +75,10 @@
color: var(--color-text);
}
/* 칩 = 부품 배지(알약) · 색은 상태마다 부품 색 · 없어진 원본 = 빨강 점선 */
.b08-link__chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
max-width: 100%;
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
}
.b08-link__chip.is-missing {
border-color: var(--color-danger);
}
/* 칩 테두리 상태 색(170-3) — 연결됨 · 마스터가 바뀜 · 고침 있음 · 원본 없어짐(빨강 점선) */
.b08-link__chip.is-linked {
border-color: var(--color-success);
}
.b08-link__chip.is-changed {
border-color: var(--color-warning);
}
.b08-link__chip.is-edited {
border-color: var(--color-info);
}
.b08-link__chip.is-gone {
@@ -128,17 +107,3 @@
.b08-link__badges:empty {
display: none;
}
/* 배지(163-4) — 고침 있음 · 마스터가 바뀜(경고) */
.b08-link__badge {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-caption);
color: var(--color-text);
}
.b08-link__badge.is-warn {
border-color: var(--color-warning);
font-weight: var(--font-weight-semibold);
}
+2 -4
View File
@@ -15,6 +15,7 @@
import "./B08_Quantity_UI_Drawing.css";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { openModal } from "@ui/ui_template_modal";
import { showToast } from "@ui/ui_template_elements";
import { PART_OF, projectItems, withCopyValues } from "@ui/ui_template_item_picker_project";
@@ -37,10 +38,7 @@ function L(key: keyof typeof ui_locales): string {
}
function note(text: string): HTMLElement {
const node = document.createElement("p");
node.className = "ui-general-block__empty";
node.textContent = text;
return node;
return createText(text, "muted", 14);
}
export interface SheetsRequest {
+12 -21
View File
@@ -12,8 +12,10 @@
* 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3)
* ========================================================================== */
import { createButton, createCard } from "@ui/ui_template_elements";
import { createCard } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { createDataTable } from "@ui/ui_template_data_table";
import { fillTabs } from "../A00_Common/b_page_tabs";
import { footNote } from "./B08_Quantity_UI_Foot";
import {
EARTHWORK_SHEETS,
@@ -29,10 +31,7 @@ import {
import { onOptionsChange } from "./B08_Quantity_UI_Options";
export function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
return createText(text, "muted", 14);
}
/** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */
@@ -78,26 +77,18 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro
const { table, interval, applyRock } = loaded;
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs";
tabs.setAttribute("role", "tablist");
tabs.className = "b08-quantity__tabs b08-earthwork__tabs";
const view = document.createElement("div");
let current: SheetId = "summary";
const select = (picked: SheetId): void => {
current = picked;
tabs.replaceChildren(
...SHEETS.map(({ id, label }) => {
const button = createButton({
label,
variant: id === picked ? "filled" : "ghost",
size: "sm",
onClick: () => id !== picked && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === picked));
button.dataset.b08EarthworkSheet = id;
return button;
}),
);
fillTabs(tabs, {
tabs: SHEETS.map(({ id, label }) => ({ key: id, label })),
value: picked,
kind: "pill",
data: "b08EarthworkSheet",
onChange: (id) => select(id as SheetId),
});
if (picked === "sheet") renderSheet(view, table, interval);
else if (picked === "summary") renderEarthworkSummary(view, table);
else if (picked === "haul") renderHaulDistance(view, table);
+3 -3
View File
@@ -5,11 +5,11 @@
* ========================================================================== */
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createText } from "@ui/ui_template_text";
export function footNote(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty b08-quantity__foot";
p.textContent = text;
const p = createText(text, "muted");
p.classList.add("b08-quantity__foot");
attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null));
return p;
}
+1 -8
View File
@@ -2,11 +2,8 @@
색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만
· 굵게 + 붉은 계열은 중요 결과(.is-key)만. */
/* 보조 탭 + [저장] 한 줄 = 부품 도구줄 · 여기는 아래 틈만 */
.b08-hydro__toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-16);
margin-bottom: var(--spacing-16);
}
@@ -16,10 +13,6 @@
min-width: 0;
}
.b08-hydro__toolbar > .ui-btn {
flex: 0 0 auto;
}
.b08-hydro__sheet {
container-type: inline-size; /* 2열 ↔ 1열 = 창이 아닌 이 칸 폭 기준 */
color: var(--color-text-body);
+16 -23
View File
@@ -18,7 +18,10 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { clearState, writeState } from "../A00_Common/b_page_state";
import { createToolbar } from "@ui/ui_template_toolbar";
import { fillTabs } from "../A00_Common/b_page_tabs";
import { stationLabel } from "../common_util/common_util_station";
import {
hydroCalc,
@@ -36,10 +39,7 @@ import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quant
export { request };
function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
return createText(text, "muted", 14);
}
/** `onSave` = 페이지 이동 전 저장 등록(481-2) — 고친 측점 없으면 요청 없이 참. */
@@ -68,17 +68,15 @@ export async function renderHydro(
const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts);
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs";
tabs.setAttribute("role", "tablist");
tabs.className = "b08-quantity__tabs b08-hydro__tabs";
const save = createButton({
label: "저장",
variant: "filled",
onClick: () => void saveAll(),
});
save.dataset.b08HydroSave = "";
const toolbar = document.createElement("div");
toolbar.className = "b08-hydro__toolbar";
toolbar.append(tabs, save);
const toolbar = createToolbar({ items: [tabs, save], size: 32 });
toolbar.classList.add("b08-hydro__toolbar");
const page = document.createElement("div");
host.replaceChildren(toolbar, page);
let seq = 0;
@@ -87,22 +85,17 @@ export async function renderHydro(
const open = (index: number): void => {
openIndex = index;
const station = sheet.stations[index];
tabs.replaceChildren(
...sheet.stations.map((s, i) => {
fillTabs(tabs, {
tabs: sheet.stations.map((s, i) => {
const label = stationLabel(s.chainage_m, interval);
const button = createButton({
label: s.kind ? `${label} ${s.kind}` : label,
variant: i === index ? "filled" : "ghost",
size: "sm",
onClick: () => i !== index && open(i),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(i === index));
button.dataset.b08HydroStation = String(s.chainage_m);
button.title = s.kind || s.facility;
return button;
return { key: String(i), label: s.kind ? `${label} ${s.kind}` : label };
}),
);
value: String(index),
kind: "pill",
data: "b08HydroStation",
title: (key) => sheet.stations[Number(key)].kind || sheet.stations[Number(key)].facility,
onChange: (key) => open(Number(key)),
});
const draw = (inputs: HydroInputs): void => {
const view = buildHydroSheet(
stationLabel(station.chainage_m, interval),
+21 -8
View File
@@ -15,6 +15,7 @@
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import {
projectItems,
structureFolder,
@@ -102,6 +103,22 @@ async function update(projectId: string, info: ProjectItemInfo): Promise<boolean
}
}
type ChipState = "is-linked" | "is-missing" | "is-changed" | "is-edited" | "is-gone";
const CHIP_TONE: Record<ChipState, BadgeTone> = {
"is-linked": "success",
"is-missing": "danger",
"is-changed": "warning",
"is-edited": "info",
"is-gone": "danger",
};
/** 칩 = 부품 배지(알약 20) 꼴 · 색 = 상태(연결 초록 · 미연결 빨강 · 바뀜 노랑 · 고침 파랑 · 없어짐 빨강 점선) */
function toneChip(chip: HTMLElement, state: ChipState): void {
const look = createBadge("", CHIP_TONE[state], { size: 20 });
chip.className = `${look.className} b08-link__chip ${state}`;
chip.dataset.size = look.dataset.size ?? "20";
}
/** 칩 배지 · 테두리 색 · [업데이트] — 사본 목록(원본판 대조)을 뒤에서 받아 칠함 · 못 받으면 단추 꺼진 채. */
async function paintCopyState(
projectId: string,
@@ -116,12 +133,8 @@ async function paintCopyState(
.then((rows) => rows.find((row) => row.폴더 === folder) ?? null)
.catch(() => null);
if (!info) return null;
const badge = (key: keyof typeof ui_locales, warn: boolean): HTMLElement => {
const node = document.createElement("span");
node.className = warn ? "b08-link__badge is-warn" : "b08-link__badge";
node.textContent = L(key);
return node;
};
const badge = (key: keyof typeof ui_locales, warn: boolean): HTMLElement =>
createBadge(L(key), warn ? "warning" : "neutral", { size: 18 });
if (info.고침) badges.append(badge("B08_Quantity_Link_Edited", false));
if (info.원본없음) badges.append(badge("B08_Quantity_Link_Gone", true));
else if (info.마스터바뀜) badges.append(badge("B08_Quantity_Link_Changed", true));
@@ -133,7 +146,7 @@ async function paintCopyState(
: info.고침
? "is-edited"
: null;
if (tone) chip.classList.replace("is-linked", tone);
if (tone) toneChip(chip, tone);
button.disabled = !info.마스터바뀜 || !!info.원본없음;
button.title = button.disabled
? L("B08_Quantity_Link_UpdateSame")
@@ -173,7 +186,7 @@ export function linkSection(
line.append(head);
for (const slot of facility.slots) {
const chip = document.createElement("span");
chip.className = slot.linked ? "b08-link__chip is-linked" : "b08-link__chip is-missing";
toneChip(chip, slot.linked ? "is-linked" : "is-missing");
chip.dataset.b08LinkSlot = slot.copy;
const name = document.createElement("span");
name.className = "b08-link__name";
+5 -7
View File
@@ -21,7 +21,9 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { createDataTable } from "@ui/ui_template_data_table";
import { createToolbar } from "@ui/ui_template_toolbar";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import {
blankUnitRows,
@@ -53,10 +55,7 @@ const HINTS: Record<MaterialSheetId, string> = {
const HIDDEN = "값 없는 줄은 숨김";
function note(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = text;
return p;
return createText(text, "muted", 14);
}
/** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */
@@ -138,8 +137,8 @@ export async function renderMaterialSheet(
const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() });
save.dataset.b08MaterialSave = "";
const toolbar = document.createElement("div");
toolbar.className = "b08-material__toolbar";
const toolbar = createToolbar({ items: ["spacer", save], size: 32 });
toolbar.classList.add("b08-material__toolbar");
const notes = document.createElement("div");
notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`));
if (sheet !== "quantity") {
@@ -158,7 +157,6 @@ export async function renderMaterialSheet(
const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시
concreteNote.dataset.b08MaterialConcreteWhy = "";
if (sheet === "concrete") notes.append(concreteNote);
toolbar.append(save);
let built = materialTable(sheet, sheets, overrides);
const table = createDataTable({
+4 -20
View File
@@ -41,12 +41,6 @@
min-width: 160px;
}
.b08-milestone__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.b08-milestone__image {
max-width: 100%;
max-height: 70vh;
@@ -61,8 +55,6 @@
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
gap: 6px;
}
.b08-milestone__diagram {
@@ -157,14 +149,13 @@
.b08-milestone__leg-edit {
position: fixed;
z-index: 1000;
}
.b08-milestone__leg-edit > .ui-float-panel__body {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.b08-milestone__leg-edit .ui-input {
@@ -251,14 +242,7 @@
stroke-dasharray: none;
}
/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */
.b08-milestone__route-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-16);
}
/* 157 경로 탭 머리 줄 = 부품 도구줄(이름 · 주소 왼 + 단추 오른) */
.b08-milestone__route-title {
display: flex;
flex-wrap: wrap;
+23 -27
View File
@@ -17,8 +17,10 @@
import "./B08_Quantity_UI_Milestone.css";
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { el, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createText } from "@ui/ui_template_text";
import { fillTabs } from "../A00_Common/b_page_tabs";
import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base";
import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal";
import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link";
@@ -146,10 +148,11 @@ function tableView(projectId: string): MilestoneView {
};
let mode = "false:false";
let table = makeTable(milestoneColumns(false, false));
const none = el("p", {
className: "ui-general-block__empty",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
});
const none = createText(
"경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
"muted",
14,
);
const refresh = (): void => {
const data = liveMilestone(projectId);
const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null);
@@ -179,8 +182,7 @@ function tableView(projectId: string): MilestoneView {
export function renderMilestone(host: HTMLElement, projectId: string): void {
const tabs = el("div", {
className: "b08-quantity__tabs ui-subtabs b08-structure__tabs",
attrs: { role: "tablist" },
className: "b08-quantity__tabs b08-structure__tabs",
});
const view = el("div");
let current = "table";
@@ -188,22 +190,19 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
const drawTabs = (): void => {
const routes = liveMilestone(projectId).routes;
const tab = (id: string, label: string): HTMLButtonElement => {
const button = createButton({
label,
variant: id === current ? "filled" : "ghost",
size: "sm",
onClick: () => id !== current && open(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === current));
button.dataset.b08MilestoneTab = id;
return button;
};
tabs.replaceChildren(
tab("table", "거리이정표"),
...routes.map((r, i) => tab(r.id, r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`)),
);
fillTabs(tabs, {
tabs: [
{ key: "table", label: "거리이정표" },
...routes.map((r, i) => ({
key: r.id,
label: r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`,
})),
],
value: current,
kind: "pill",
data: "b08MilestoneTab",
onChange: open,
});
};
function open(id: string): void {
@@ -213,10 +212,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void {
view.replaceChildren(body.root);
}
host.replaceChildren(
tabs,
el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }),
);
host.replaceChildren(tabs, createText("거리이정표를 불러오는 중…", "muted", 14));
loadMilestone(projectId).then(
() => {
host.replaceChildren(tabs, view);
@@ -19,6 +19,8 @@
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createToolbar } from "@ui/ui_template_toolbar";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import {
midKm,
@@ -94,7 +96,10 @@ function editLegAt(
km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값
km.title = `자동 값 ${fmtKm(leg.auto_km)} km`;
}
const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] });
const pop = createFloatPanel({ popover: true });
pop.body.append(road.root, km);
const box = pop.root;
box.classList.add("b08-milestone__leg-edit");
box.dataset.b08MilestoneLegEdit = `${from}:${i}`;
const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로
box.style.left = `${at.left}px`;
@@ -300,7 +305,8 @@ export function createDiagram(
onClick: () => openPlaceModal(projectId, { add: true }, addRoute),
});
addElem.dataset.b08MilestoneAdd = "open";
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
const tools = createToolbar({ items: [arrange, addElem], size: 32, glass: true });
tools.classList.add("b08-milestone__tools");
/** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */
const picUrls = new Map<string, { key: string; url: Promise<string> }>();
@@ -14,6 +14,7 @@
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import {
ROADS,
roadOf,
@@ -113,7 +114,7 @@ export function legsEditor(
};
}
const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text });
const hint = (text: string): HTMLElement => createText(text, "muted");
/** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */
function openMidLeg(projectId: string): void {
@@ -17,6 +17,8 @@ import {
type MapContextMenuItem,
} from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { chainOf, ownerOf, reachesSite } from "./B08_Quantity_Milestone_Layout";
import { DEST_TEXT } from "./B08_Quantity_Milestone_Table";
@@ -76,6 +78,13 @@ export interface MilestoneView {
refresh: () => void;
}
/** 머리 줄 — 이름 · 주소(왼) + 단추(오른) = 부품 도구줄 */
function routeHead(title: HTMLElement, buttons: HTMLElement[]): HTMLElement {
const bar = createToolbar({ items: [title, "spacer", ...buttons], size: 32 });
bar.classList.add("b08-milestone__route-head");
return bar;
}
export function routeView(projectId: string, routeId: string): MilestoneView {
// 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기
const name = el("strong", { className: "b08-milestone__route-name" });
@@ -155,11 +164,11 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
return [routeId, ...chain].map((id) => segmentPath(projectId, id)).filter((p) => p.length >= 2);
};
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
const status = el("p", { className: "b08-options__hint" });
const status = createText("", "muted");
status.dataset.b08MilestoneRouteStatus = "";
status.hidden = true;
// 373 안 이은 기준점에 이음 = 시점까지 안 닿음 안내(이은 거래처만)
const open = el("p", { className: "b08-options__hint" });
const open = createText("", "muted");
open.dataset.b08MilestoneRouteOpen = "";
open.hidden = true;
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
@@ -207,13 +216,12 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
className: "b08-milestone__route ui-card",
attrs: { "data-b08-milestone-route": routeId },
children: [
el("div", {
className: "b08-milestone__route-head",
children: [
el("div", { className: "b08-milestone__route-title", children: [name, address] }),
el("div", { className: "b08-milestone__bar", children: [place, change, reset, file] }),
],
}),
routeHead(el("div", { className: "b08-milestone__route-title", children: [name, address] }), [
place,
change,
reset,
file,
]),
status,
open,
img,
+2 -4
View File
@@ -26,6 +26,7 @@ import {
} from "@ui/ui_template_elements";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { clampPct } from "./B08_Quantity_Options_Calc";
@@ -97,10 +98,7 @@ function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[]
}
function hint(text: string): HTMLElement {
const p = document.createElement("p");
p.className = "b08-options__hint";
p.textContent = text;
return p;
return createText(text, "muted");
}
/** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림.
+13 -14
View File
@@ -7,16 +7,22 @@
padding: var(--spacing-16) var(--spacing-24); /* M02 본문(.m02-main)과 같은 값 */
}
.b08-quantity .ui-general-block__empty {
color: var(--color-text-secondary); /* muted 는 밝은 테마에서 대비 3:1 — 4.5:1 로 */
/* 부품 보조 글은 display:flex 라 hidden 을 이김 — 숨김 켜고 끄는 글이 많아 한 번에 */
.b08-quantity .ui-text[hidden] {
display: none;
}
/* 탭 줄 모양 = 부품 탭(밑줄 · 보조 알약) · 여기는 줄바꿈 · 아래 틈만 */
.b08-quantity__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.b08-quantity > .b08-quantity__tabs {
margin-bottom: var(--spacing-8);
}
.b08-quantity__tabs.ui-subtabs {
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* ☰ 들여쓰기(7-2) */
@@ -31,6 +37,7 @@ html.is-bm-route .b08-quantity > .b08-quantity__tabs:first-child {
/* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */
.b08-quantity__foot {
display: block; /* 부품 보조 글은 flex — 줄임표는 블록에서 */
min-width: 0;
max-width: 100%;
overflow: hidden;
@@ -39,19 +46,11 @@ html.is-bm-route .b08-quantity > .b08-quantity__tabs:first-child {
font-size: var(--text-caption);
}
/* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */
/* 자재 집계표 셋(121) — [저장] 한 줄 = 부품 도구줄 · 아래 틈만 */
.b08-material__toolbar {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: var(--spacing-16);
margin-bottom: var(--spacing-8);
}
.b08-material__toolbar > .ui-btn {
flex: 0 0 auto;
}
/* 구조물 · 자재 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 131 · 공용 표 CSS 는 그대로) */
[data-b08-summary="table"] thead th,
[data-b08-material] thead th {
+32 -40
View File
@@ -13,11 +13,12 @@
import "@ui/ui_template_general_layout.css";
import "./B08_Quantity_UI_Page.css";
import { createText } from "@ui/ui_template_text";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createButton } from "@ui/ui_template_elements";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { fillTabs } from "../A00_Common/b_page_tabs";
import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav";
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
@@ -53,33 +54,22 @@ type SheetId = (typeof SHEETS)[number]["id"];
type TabSave = (host: HTMLElement, save: () => Promise<boolean>) => void;
function pendingNote(): HTMLElement {
const pending = document.createElement("p");
pending.className = "ui-general-block__empty";
pending.textContent = L("B08_Quantity_Tab_Pending");
return pending;
return createText(L("B08_Quantity_Tab_Pending"), "muted", 14);
}
/** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */
function renderMaterials(host: HTMLElement, projectId: string | null, onSave: TabSave): void {
const tabs = document.createElement("div");
tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs";
tabs.setAttribute("role", "tablist");
tabs.className = "b08-quantity__tabs b08-structure__tabs";
const view = document.createElement("div");
const select = (picked: SheetId): void => {
tabs.replaceChildren(
...SHEETS.map(({ id, label }) => {
const button = createButton({
label: L(label),
variant: id === picked ? "filled" : "ghost",
size: "sm",
onClick: () => id !== picked && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === picked));
button.dataset.b08StructureSheet = id;
return button;
}),
);
fillTabs(tabs, {
tabs: SHEETS.map(({ id, label }) => ({ key: id, label: L(label) })),
value: picked,
kind: "pill",
data: "b08StructureSheet",
onChange: (id) => select(id as SheetId),
});
const body = document.createElement("div");
view.replaceChildren(body);
if (!projectId) body.append(pendingNote());
@@ -95,13 +85,21 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSa
host.replaceChildren(view);
if (tab === "material") return renderMaterials(view, projectId, onSave);
if (tab === "summary" && projectId) {
// 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐
// 보조 탭 줄 자리(170-4) — 보이지 않는 보조 탭 한 칸 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐
const blank = document.createElement("div");
blank.className = "b08-quantity__tabs ui-subtabs b08-quantity__tabs--blank";
blank.className = "b08-quantity__tabs b08-quantity__tabs--blank";
fillTabs(blank, {
tabs: [{ key: SHEETS[0].id, label: L(SHEETS[0].label) }],
value: SHEETS[0].id,
kind: "pill",
data: "b08StructureSheet",
onChange: () => undefined,
}).forEach((button) => {
button.tabIndex = -1;
delete button.dataset.b08StructureSheet;
});
blank.setAttribute("aria-hidden", "true");
const ghost = createButton({ label: L(SHEETS[0].label), variant: "ghost", size: "sm" });
ghost.tabIndex = -1;
blank.append(ghost);
blank.setAttribute("role", "presentation");
const body = document.createElement("div");
view.append(blank, body);
return void renderStructureSummary(body, projectId);
@@ -118,7 +116,6 @@ export function renderB08Quantity(root: HTMLElement): void {
const tabBar = document.createElement("div");
tabBar.className = "b08-quantity__tabs";
tabBar.setAttribute("role", "tablist");
const panel = document.createElement("div");
panel.className = "b08-quantity__panel";
panel.setAttribute("role", "tabpanel");
@@ -128,19 +125,14 @@ export function renderB08Quantity(root: HTMLElement): void {
const onTabSave: TabSave = (host, save) => (tabSave = { host, save });
const select = (tab: TabId): void => {
current = tab;
tabBar.replaceChildren(
...TABS.map(({ id, label }) => {
const button = createButton({
label: L(label),
variant: id === tab ? "filled" : "ghost",
onClick: () => id !== tab && select(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === tab));
button.dataset.b08Tab = id;
return button;
}),
);
fillTabs(tabBar, {
tabs: TABS.map(({ id, label }) => ({ key: id, label: L(label) })),
value: tab,
kind: "line",
size: 32,
data: "b08Tab",
onChange: (id) => select(id as TabId),
});
renderTab(panel, tab, projectId, onTabSave);
};
+2 -4
View File
@@ -20,6 +20,7 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createCard, showToast } from "@ui/ui_template_elements";
import { createDataTable } from "@ui/ui_template_data_table";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
@@ -39,10 +40,7 @@ function L(key: keyof typeof ui_locales): string {
}
function note(text: string): HTMLElement {
const node = document.createElement("p");
node.className = "ui-general-block__empty";
node.textContent = text;
return node;
return createText(text, "muted", 14);
}
function buildTable(
+8 -9
View File
@@ -8,6 +8,8 @@
* ========================================================================== */
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createBadge } from "@ui/ui_template_badge";
import { createText } from "@ui/ui_template_text";
import type { Row } from "./B09_Estimation_Calc_Base";
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
@@ -60,9 +62,8 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
refresh.classList.toggle("is-pending", Boolean(next.stale));
const made = checkItems(sheets, next);
items = made.items;
const head = document.createElement("p");
head.className = "b09-check__head";
head.textContent = `연결 없음 ${items.length}곳`;
const head = createText(`연결 없음 ${items.length}곳`, "muted");
head.classList.add("b09-check__head");
const rows = document.createElement("div");
rows.className = "ui-sidebar-list";
rows.append(
@@ -73,18 +74,16 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
row.dataset.i = String(i);
const name = document.createElement("span");
name.textContent = item.name;
const tab = document.createElement("span");
tab.className = "b09-check__tab";
tab.textContent = item.tab;
const tab = createBadge(item.tab, "neutral", { size: 18 });
tab.classList.add("b09-check__tab");
row.append(name, tab);
row.addEventListener("click", () => goto(item.link));
return row;
}),
);
const notes = made.notes.map((text) => {
const p = document.createElement("p");
p.className = "b09-check__note";
p.textContent = text;
const p = createText(text, "muted");
p.classList.add("b09-check__note");
return p;
});
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
+9 -27
View File
@@ -9,7 +9,7 @@
* 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createSegment } from "@ui/ui_template_segment";
import { readState, writeState } from "../A00_Common/b_page_state";
import { DRAFT } from "./B09_Estimation_UI_Tabs";
import {
@@ -40,7 +40,7 @@ function setLinkMode(mode: LinkMode): void {
}
}
/** 단추 둘 스위치 — 켜진 쪽 filled · aria-pressed */
/** 단추 둘 스위치 — 부품 「분할 고르기」(32) · 고른 쪽 aria-checked */
function switchRow<T extends string>(
name: string,
values: T[],
@@ -48,32 +48,14 @@ function switchRow<T extends string>(
current: () => T,
pick: (value: T) => void,
): HTMLElement {
const row = document.createElement("div");
row.className = "ui-btn-group b09-dock__row";
row.dataset.b09Switch = name;
const buttons = values.map((value) => {
const button = createButton({
label: label(value),
variant: "ghost",
onClick: () => {
if (value === current()) return;
pick(value);
sync();
},
});
button.dataset.value = value;
return button;
const segment = createSegment({
options: values.map((value) => ({ value, label: label(value) })),
value: current(),
size: 32,
onChange: (value) => pick(value as T),
});
const sync = (): void =>
buttons.forEach((button, k) => {
const on = values[k] === current();
button.classList.toggle("ui-btn--filled", on);
button.classList.toggle("ui-btn--ghost", !on);
button.setAttribute("aria-pressed", String(on));
});
sync();
row.append(...buttons);
return row;
segment.root.dataset.b09Switch = name;
return segment.root;
}
/** 하단 고정 줄 — onView = 보기 바뀜(묶음 탭이면 다시 그림) */
@@ -4,10 +4,8 @@
flex-direction: column;
gap: var(--spacing-12);
}
.b09-haulstep {
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
.b09-haulstep.ui-card {
padding: 0; /* 부품 카드 · 머리 · 바닥 줄이 가장자리까지 */
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
@@ -83,7 +83,7 @@ const group = (label: string, rows: StepRow[], ats?: number[]): HTMLElement[] =>
/** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */
function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[]): HTMLElement {
return el("section", {
className: "b09-haulstep",
className: "b09-haulstep ui-card",
children: [
el("div", {
className: "b09-haulstep__head",
+35 -48
View File
@@ -8,22 +8,17 @@
padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */
}
.b09-estimation .ui-general-block__empty,
.b09-estimation__hint {
color: var(--color-text-secondary);
}
.b09-estimation__hint {
margin: 0 0 var(--spacing-8);
font-size: var(--text-caption);
}
/* 탭 줄 모양 = 부품 탭(밑줄 · 보조 알약) · 여기는 줄바꿈 · 아래 틈만(B08 과 같은 값) */
.b09-estimation__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */
}
.b09-estimation > .b09-estimation__tabs:first-child {
margin-bottom: var(--spacing-8);
}
.b09-estimation__tabs.ui-subtabs {
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* ☰ 들여쓰기(7-2) */
@@ -59,12 +54,13 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
}
/* 202-1 안내 줄 = 표 · 카드 판 아래 · 203 줄 수 변수만큼 보임 · 넘치면 영역 안 스크롤 */
.b09-estimation .ui-data-table + .ui-general-block__empty,
.b09-estimation .ui-group-cards + .ui-general-block__empty {
.b09-estimation .ui-data-table + .ui-text,
.b09-estimation .ui-group-cards + .ui-text {
margin: var(--spacing-8) 0 0;
}
.b09-estimation__line-hint {
display: block; /* 부품 보조 글은 flex — 줄 수 · 스크롤은 블록에서 */
min-width: 0;
max-height: calc(var(--b09-hint-lines) * 1lh);
overflow-y: auto;
@@ -192,15 +188,11 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
.b09-check__head,
.b09-check__note {
margin: var(--spacing-8) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
margin-top: var(--spacing-8);
}
.b09-check__tab {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */
@@ -232,15 +224,11 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
background: var(--color-canvas);
}
.b09-estimation__drop-tag {
.b09-estimation__drop > .b09-estimation__drop-tag.ui-badge {
position: absolute;
top: -11px;
padding: 1px var(--spacing-8);
border: 1px solid;
border-radius: var(--radius-pills);
border: 1px solid; /* 놓을 자리 색(--drop-c)을 따름 */
background: var(--color-surface-raised);
font-size: var(--text-caption);
white-space: nowrap;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-drop-into td {
@@ -266,44 +254,43 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
.b09-dock .b09-dock__row {
display: flex;
gap: var(--btn-group-gap);
min-width: 0;
}
/* 보기 · 행 누름 스위치 = 부품 분할 고르기 · 줄 폭을 나눠 가짐 */
.b09-dock > .ui-segment {
display: flex;
}
.b09-dock > .ui-segment > .ui-segment__item {
flex: 1 1 0;
min-width: 0;
}
/* 줄 폭을 나눠 가짐 · 가로 넘침 없음(min-width 0 = 공용 단추 줄의 auto 보다 앞섬 · 글 줄임표) — 인쇄 미리보기 좌측 줄도 같은 규칙 */
.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn, .ui-step-nav-btn) {
flex: 1 1 0;
min-width: auto;
min-width: 0;
padding-inline: var(--spacing-4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임 · 창 안 b09 표 꼴 그대로 · 빨강 테두리) */
.b09-dock .b09-dock__row > .ui-btn[hidden] {
display: none;
}
/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임) = 부품 떠 있는 판 · 여기는 자리 · 크기만 */
.b09-estimation.b09-popup {
position: fixed;
z-index: var(--z-modal);
gap: var(--spacing-4);
width: max-content;
max-width: 90vw;
max-height: 80vh;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.b09-popup__head {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-4);
}
.b09-popup__title {
margin-right: auto;
font-weight: var(--font-weight-bold);
padding: 0;
}
.b09-popup .ui-data-table {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}
+12 -18
View File
@@ -12,6 +12,7 @@
import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table";
import { createButton } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { placeHoverInfo } from "@ui/ui_template_hover_info";
import { tableOptionsOf } from "./B09_Estimation_UI_Builders";
import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs";
@@ -135,35 +136,28 @@ export function openRowPopup(o: PopupOpen): void {
if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김
listen();
const level = stack.length;
const box = document.createElement("div");
box.className = "b09-estimation b09-popup ui-popup";
// 떠 있는 판(부품) — 머리 = 제목 + [탭 이동] + 닫기 ✕ · 몸 = 표 조각
const panel = createFloatPanel({ title: tabName(tab), onClose: () => closePopups(level) });
const box = panel.root;
box.classList.add("b09-estimation", "b09-popup", "ui-popup");
box.setAttribute("role", "dialog");
box.setAttribute("aria-label", tabName(tab));
box.dataset.b09Popup = tab;
const title = document.createElement("span");
title.className = "b09-popup__title";
title.textContent = tabName(tab);
const head = box.querySelector<HTMLElement>(".ui-float-panel__head") as HTMLElement;
head.classList.add("b09-popup__head");
const closeButton = head.querySelector<HTMLElement>(".ui-icon-btn") as HTMLElement;
closeButton.dataset.b09PopupClose = "";
const goButton = createButton({
label: "탭 이동",
variant: "ghost",
size: "sm",
size: 24,
onClick: () => {
closePopups();
o.go(o.link);
},
});
goButton.dataset.b09PopupGo = "";
const closeButton = createButton({
label: "×",
variant: "ghost",
size: "sm",
onClick: () => closePopups(level),
});
closeButton.dataset.b09PopupClose = "";
closeButton.setAttribute("aria-label", "닫기");
const head = document.createElement("div");
head.className = "b09-popup__head";
head.append(title, goButton, closeButton);
closeButton.before(goButton);
const from = range[0];
const table = createDataTable({
...popupOptions(spec, tab, range),
@@ -181,7 +175,7 @@ export function openRowPopup(o: PopupOpen): void {
},
});
table.root.dataset.b09Table = tab;
box.append(head, table.root);
panel.body.append(table.root);
document.body.append(box);
stack.push(box);
const view = { width: window.innerWidth, height: window.innerHeight };
+2 -4
View File
@@ -14,6 +14,7 @@ import "@ui/ui_template_general_layout.css";
import "./B09_Estimation_UI_Page.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { createText } from "@ui/ui_template_text";
import {
createButton,
hideLoadingOverlay,
@@ -79,10 +80,7 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
}
function note(textValue: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = textValue;
return p;
return createText(textValue, "muted", 14);
}
/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸(+ sg_hand = 손 줄 로직 → 산근 키 · 166-2) */
+35 -43
View File
@@ -1,14 +1,15 @@
/* =============================================================================
* B09_Estimation_UI_TabBar.ts
* B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴.
* B09 탭 줄 두 층(PLAN 181-1) — 위 탭(밑줄 · 32) · 아래 탭(보조 알약) · B08 수량산출 페이지 꼴(부품 「탭」).
*
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움)
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 보조 탭 한 칸(높이만 채움)
* 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭
* 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐
* 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createToolbar } from "@ui/ui_template_toolbar";
import { fillTabs } from "../A00_Common/b_page_tabs";
import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs";
export interface TabBars {
@@ -20,58 +21,49 @@ export interface TabBars {
setTools: (tools: HTMLElement[]) => void;
}
function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) {
const button = createButton({
label,
variant: on ? "filled" : "ghost",
...(size ? { size } : {}),
onClick: () => !on && pick(),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(on));
return button;
}
export function createTabBars(onPick: (tab: TabId) => void): TabBars {
const top = document.createElement("div");
top.className = "b09-estimation__tabs";
top.setAttribute("role", "tablist");
const sub = document.createElement("div");
sub.className = "b09-estimation__tabs ui-subtabs";
const tools = document.createElement("div");
tools.className = "b09-estimation__tools ui-subtabs__tools";
sub.className = "b09-estimation__tabs";
const tools = createToolbar({ items: [], size: 24 });
tools.classList.add("b09-estimation__tools", "ui-subtabs__tools");
const lastSeen = new Map<TabId, TabId>();
const draw = (tab: TabId): void => {
const [, picked] = groupOf(tab);
lastSeen.set(picked[0], tab);
top.replaceChildren(
...TAB_GROUPS.map(([label, tabs]) => {
const button = tabButton(label, tabs === picked, undefined, () =>
onPick(lastSeen.get(tabs[0]) ?? tabs[0]),
);
button.dataset.b09Group = tabs[0];
if (tabs.length === 1) button.dataset.b09Tab = tabs[0];
return button;
}),
);
const groupTab = new Map(TAB_GROUPS.map(([, tabs]) => [tabs[0], tabs]));
fillTabs(top, {
tabs: TAB_GROUPS.map(([label, tabs]) => ({ key: tabs[0], label })),
value: picked[0],
kind: "line",
size: 32,
data: "b09Group",
onChange: (key) => onPick(lastSeen.get(key as TabId) ?? (key as TabId)),
}).forEach((button) => {
const tabs = groupTab.get(button.dataset.tab as TabId);
if (tabs?.length === 1) button.dataset.b09Tab = tabs[0];
});
const single = picked.length === 1;
sub.classList.toggle("b09-estimation__tabs--blank", single);
const buttons = fillTabs(sub, {
tabs: single
? [{ key: picked[0], label: tabName(picked[0]) }]
: picked.map((id) => ({ key: id, label: tabName(id) })),
value: single ? picked[0] : tab,
kind: "pill",
data: "b09Tab",
onChange: (id) => onPick(id as TabId),
});
sub.setAttribute("role", single ? "presentation" : "tablist");
if (single) {
const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" });
ghost.tabIndex = -1;
ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음
return void sub.replaceChildren(ghost, tools);
}
sub.replaceChildren(
...picked.map((id) => {
const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id));
button.dataset.b09Tab = id;
return button;
}),
tools,
);
if (single)
for (const button of buttons) {
button.tabIndex = -1;
button.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음
delete button.dataset.b09Tab;
}
sub.append(tools);
};
const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items);
return { top, sub, draw, setTools };
@@ -9,6 +9,7 @@
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import type { DataRow } from "@ui/ui_template_data_table";
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
@@ -68,8 +69,8 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri
const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14;
const line = document.createElement("div");
line.className = "b09-estimation__drop";
const tag = document.createElement("span");
tag.className = "b09-estimation__drop-tag";
const tag = createBadge("", "neutral", { size: 18 });
tag.classList.add("b09-estimation__drop-tag");
line.append(tag);
const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`);
let into: HTMLElement | null = null;
@@ -16,6 +16,7 @@
import type { DataRow } from "@ui/ui_template_data_table";
import { createButton } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag";
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
@@ -149,9 +150,8 @@ export function levelBar(
button.dataset.b09Level = label;
return button;
});
const guide = document.createElement("p");
guide.className = "ui-general-block__empty b09-estimation__line-hint";
guide.textContent = GUIDE;
const guide = createText(GUIDE, "muted");
guide.classList.add("b09-estimation__line-hint");
attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만
const sync = (): void => {
const acts = menuActs(table, index());
+2 -47
View File
@@ -10,14 +10,7 @@
}
.b09-print__status {
margin: 0;
min-height: 1.4em;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.b09-print__status.is-error {
color: var(--color-danger);
min-height: 1.4em; /* 글이 비어도 줄 높이 유지 */
}
.b09-print__frame {
@@ -30,9 +23,7 @@
}
.b09-print__areas {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
flex-wrap: wrap; /* 부품 보조 탭 줄 */
}
.b09-print__list {
@@ -42,39 +33,3 @@
max-height: 50vh;
overflow: auto;
}
.b09-print__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
cursor: pointer;
}
/* 좌측 하단 두 줄(302-5 · B09 Dock 꼴) — 단추는 줄 폭을 나눠 가짐 · 가로 넘침 없음 */
.b09-print__actions.ui-sidebar-actions {
flex-direction: column;
}
.b09-print__actions.ui-sidebar-actions > * {
flex: none;
}
.b09-print__actions .b09-print__row {
display: flex;
gap: var(--btn-group-gap);
min-width: 0;
}
.ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn:not(.ui-help-btn) {
flex: 1 1 0;
min-width: 0;
padding-inline: var(--spacing-4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b09-print__actions .b09-print__row > .ui-btn[hidden] {
display: none;
}
+27 -31
View File
@@ -11,14 +11,18 @@
* ========================================================================== */
import "@ui/ui_template_general_layout.css";
import "../B09_Estimation_UI_Page.css"; // 좌측 하단 단추 줄(b09-dock) 규칙 한 벌
import "./B09_Draft_Print_Page.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import type { MountedPage } from "../../A00_Common/router";
import { navigateTo } from "../../A00_Common/router";
import { workflowSteps } from "../../A00_Common/b_page_scaffold";
import { fillTabs } from "../../A00_Common/b_page_tabs";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav";
import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table";
import type {
@@ -114,8 +118,8 @@ function save(blob: Blob, name: string): void {
export function renderB09Print(root: HTMLElement): MountedPage {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const status = document.createElement("p");
status.className = "b09-print__status";
const status = createText("", "muted");
status.className += " b09-print__status";
status.dataset.b09PrintStatus = "";
const frame = document.createElement("iframe");
frame.className = "b09-print__frame";
@@ -144,6 +148,8 @@ export function renderB09Print(root: HTMLElement): MountedPage {
const say = (text: string, error = false) => {
status.textContent = text;
status.classList.toggle("is-error", error);
status.classList.toggle("ui-text--error", error);
status.classList.toggle("ui-text--muted", !error);
};
const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id));
const clearFrame = () => {
@@ -213,21 +219,16 @@ export function renderB09Print(root: HTMLElement): MountedPage {
const drawList = () => {
list.replaceChildren(
...(area?.items ?? []).map((it) => {
const row = document.createElement("label");
row.className = "b09-print__item";
const box = document.createElement("input");
box.type = "checkbox";
box.checked = !off.has(it.id);
box.dataset.b09PrintItem = it.id;
box.addEventListener("change", () => {
if (box.checked) off.delete(it.id);
else off.add(it.id);
later();
const row = createCheckbox(it.name, {
checked: !off.has(it.id),
onChange: (on) => {
if (on) off.delete(it.id);
else off.add(it.id);
later();
},
});
const name = document.createElement("span");
name.textContent = it.name;
row.append(box, name);
return row;
row.input.dataset.b09PrintItem = it.id;
return row.root;
}),
);
};
@@ -238,18 +239,13 @@ export function renderB09Print(root: HTMLElement): MountedPage {
off = new Set();
const my = ++seq;
clearFrame();
areaRow.replaceChildren(
...AREAS.map((a) => {
const b = createButton({
label: a.label,
size: "sm",
variant: a.id === id ? "filled" : "ghost",
onClick: () => a.id !== areaId && pickArea(a.id),
});
b.dataset.b09PrintArea = a.id;
return b;
}),
);
fillTabs(areaRow, {
tabs: AREAS.map((a) => ({ key: a.id, label: a.label })),
value: id,
kind: "pill",
data: "b09PrintArea",
onChange: (key) => pickArea(key as AreaId),
});
xlsxBtn.hidden = id === "drawings";
drawList();
if (!projectId) return say("현재 프로젝트가 없습니다.", true);
@@ -270,14 +266,14 @@ export function renderB09Print(root: HTMLElement): MountedPage {
// 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음
const files = document.createElement("div");
files.className = "b09-print__row";
files.className = "b09-dock__row b09-print__row";
files.append(pdfBtn, xlsxBtn);
const nav = document.createElement("div");
nav.className = "b09-print__row";
nav.className = "b09-dock__row b09-print__row";
nav.dataset.helpRow = "";
nav.append(back);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions b09-print__actions";
actions.className = "ui-sidebar-actions b09-dock b09-print__actions";
actions.append(files, nav);
const left = document.createElement("div");
left.className = "ui-sidebar-panel b09-print__panel";
+56 -50
View File
@@ -6,8 +6,9 @@
* ========================================================================== */
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, el } from "@ui/ui_template_elements";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { e06Table, note } from "./E06_StabRock_UI_Parts";
export interface JointSet {
id: string;
@@ -55,7 +56,14 @@ export interface JointsView {
rebuild: () => void; // 줄 수가 바뀜
}
/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */
/** 틀린 칸 표시 — 부품 오류 상태(`is-error` · aria-invalid) */
function markInvalid(input: HTMLElement, bad: boolean): void {
input.classList.toggle("is-error", bad);
if (bad) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
}
/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 오류 표시 · 바꾸면 set 부르고 change */
function numberInput(
view: JointsView,
value: number | null,
@@ -64,17 +72,17 @@ function numberInput(
opts: { optional?: boolean; step?: string; path: string },
): HTMLInputElement {
const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity];
const input = el("input", {
className: "e06-input",
attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path },
}) as HTMLInputElement;
const input = createInputField({ type: "number", size: 24 }).input;
input.classList.add("e06-input");
input.step = opts.step ?? "any";
input.dataset.path = opts.path;
input.value = value === null ? "" : String(value);
input.disabled = !view.editable;
const check = (): boolean => {
const raw = input.value.trim();
const num = Number(raw);
const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi;
input.classList.toggle("is-invalid", !ok);
markInvalid(input, !ok);
input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`;
return ok;
};
@@ -107,64 +115,61 @@ const COLUMNS: LocaleKey[] = [
function jointTable(view: JointsView): HTMLElement {
const wrap = el("div", { className: "e06-table-wrap" });
const table = el("table", { className: "e06-table" });
const head = el("tr");
for (const key of COLUMNS) head.append(el("th", { text: t(key) }));
head.append(el("th"));
table.append(el("thead"), el("tbody"));
table.tHead!.append(head);
view.doc.sets.forEach((row, k) => {
const tr = el("tr", { attrs: { "data-id": row.id } });
const name = el("input", {
className: "e06-input e06-input--name",
attrs: { type: "text", "data-path": `sets.${k}.name` },
}) as HTMLInputElement;
const sets = view.doc.sets;
const fields: [keyof JointSet, string, boolean][] = [
["from_m", "chainage_m", false],
["to_m", "chainage_m", false],
["dip_direction", "dip_direction", false],
["dip", "dip", false],
["phi", "phi", false],
["c", "c", false],
["persistence_m", "persistence_m", true],
];
const rows = sets.map((row, k) => {
const name = createInputField({ size: 24 }).input;
name.classList.add("e06-input", "e06-input--name");
name.dataset.path = `sets.${k}.name`;
name.value = row.name;
name.disabled = !view.editable;
name.addEventListener("input", () => {
row.name = name.value;
name.classList.toggle("is-invalid", !name.value.trim());
markInvalid(name, !name.value.trim());
view.change();
});
tr.append(el("td", { children: [name] }));
const fields: [keyof JointSet, string, boolean][] = [
["from_m", "chainage_m", false],
["to_m", "chainage_m", false],
["dip_direction", "dip_direction", false],
["dip", "dip", false],
["phi", "phi", false],
["c", "c", false],
["persistence_m", "persistence_m", true],
];
const cells: HTMLElement[] = [name];
for (const [key, limit, optional] of fields) {
const input = numberInput(
view,
row[key] as number | null,
limit,
(v) => ((row as unknown as Record<string, number | null>)[key] = v),
{ optional, path: `sets.${k}.${key}` },
cells.push(
numberInput(
view,
row[key] as number | null,
limit,
(v) => ((row as unknown as Record<string, number | null>)[key] = v),
{ optional, path: `sets.${k}.${key}` },
),
);
tr.append(el("td", { children: [input] }));
}
const remove = createButton({
label: "×",
variant: "danger-ghost",
size: "sm",
onClick: () => {
view.doc.sets.splice(k, 1);
sets.splice(k, 1);
view.rebuild();
},
});
remove.title = t("E06_Remove");
remove.disabled = !view.editable;
tr.append(el("td", { children: [remove] }));
table.tBodies[0].append(tr);
cells.push(remove);
return cells;
});
wrap.append(table);
if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") }));
const list = e06Table([...COLUMNS.map((key) => t(key)), ""], rows, "e06-table");
list.rows.forEach((tr, k) => (tr.dataset.id = sets[k].id));
wrap.append(list.root);
if (!sets.length) wrap.append(note(t("E06_NoSets")));
return wrap;
}
/** 좁은 패널 설정 줄 — 설정 줄 부품(줄 52 · 큰 글)은 이 폭에 안 맞아 옛 꼴 그대로 */
function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement {
const row = el("label", { className: "e06-setting" });
row.append(el("span", { text: t(label) }), control);
@@ -178,11 +183,12 @@ function select<T extends string>(
set: (v: T) => void,
path: string,
): HTMLSelectElement {
const box = el("select", {
className: "e06-input",
attrs: { "data-path": path },
}) as HTMLSelectElement;
for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } }));
const box = createSelectField({
options: options.map(([v, key]) => ({ value: v, text: t(key) })),
size: 24,
}).select;
box.classList.add("e06-input");
box.dataset.path = path;
box.value = value;
box.disabled = !view.editable;
box.addEventListener("change", () => {
@@ -241,7 +247,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
});
add.disabled = !view.editable;
setsCard.append(
el("p", { className: "e06-hint", text: t("E06_JointsHint") }),
note(t("E06_JointsHint")),
jointTable(view),
el("div", { className: "e06-row-actions", children: [add] }),
);
@@ -324,8 +330,8 @@ export function buildJointCards(view: JointsView): HTMLElement[] {
path: "settings.bolt.plunge",
}),
),
el("p", { className: "e06-hint", text: t("E06_SettingsHint") }),
el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }),
note(t("E06_SettingsHint")),
note(t("E06_MinBlockHint")),
);
return [setsCard, setCard];
}
+20 -37
View File
@@ -10,6 +10,7 @@
import "./E06_StabRock_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import {
createButton,
@@ -19,7 +20,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { t } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { mountGroupStep } from "../A00_Common/group_workflow";
import {
@@ -38,6 +39,7 @@ import {
type StationEntry,
} from "./E06_StabRock_UI_Result";
import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet";
import { emptyBox, note, toggle } from "./E06_StabRock_UI_Parts";
interface RockResponse {
joints: JointsDoc;
@@ -76,33 +78,16 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return body;
}
function toggle<T extends string>(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) {
const group = el("div", { className: "e06-toggle" });
const buttons = options.map(([key, v]) => {
const button = createButton({ label: t(key), variant: "ghost", size: "sm" });
button.dataset.value = v;
button.classList.toggle("is-on", v === value);
button.addEventListener("click", () => {
buttons.forEach((b) => b.classList.toggle("is-on", b === button));
onPick(v);
});
return button;
});
group.append(...buttons);
return group;
}
export function renderE06StabRock(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const panel = el("div", { className: "ui-sidebar-panel e06-panel" });
const main = el("div", { className: "e06-page" });
mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") }));
if (!projectId) return void main.append(emptyBox(t("E06_NoProject")));
// ---- 좌 ----
const cards = el("div", { className: "e06-cards" });
const status = el("p", { className: "e06-status" });
const status = el("div", { className: "e06-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "ghost",
@@ -121,11 +106,12 @@ export function renderE06StabRock(root: HTMLElement): void {
saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton, analyzeButton);
panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions);
panel.append(note(t("E06_Note")), cards, status, actions);
attachCollapsible(cards);
// ---- 가운데 · 우 ----
const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") });
const staleBar = createBanner({ tone: "warning", text: t("E06_Stale") });
staleBar.classList.add("e06-stale");
staleBar.hidden = true;
const bandSlot = el("div");
const title = el("strong", { className: "e06-title" });
@@ -140,10 +126,10 @@ export function renderE06StabRock(root: HTMLElement): void {
projectionToggle,
toggle<NetMode>(
[
["E06_View_All", "all"],
["E06_Mode_planar", "planar"],
["E06_Mode_wedge", "wedge"],
["E06_Mode_toppling", "toppling"],
[t("E06_View_All"), "all"],
[t("E06_Mode_planar"), "planar"],
[t("E06_Mode_wedge"), "wedge"],
[t("E06_Mode_toppling"), "toppling"],
],
netMode,
(v) => {
@@ -178,14 +164,14 @@ export function renderE06StabRock(root: HTMLElement): void {
let active: string | null = null;
let shown: StationResponse | null = null;
const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length;
const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length;
const paintStatus = (): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E06_ReadOnly"));
if (dirty) lines.push(t("E06_Unsaved"));
const bad = invalidCount();
if (bad) lines.push(`${t("E06_Invalid")} ${bad}`);
status.textContent = lines.join("\n");
status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e06-status__text")] : []));
status.classList.toggle("is-error", bad > 0);
};
@@ -211,8 +197,8 @@ export function renderE06StabRock(root: HTMLElement): void {
projectionToggle.replaceChildren(
toggle<Projection>(
[
["E06_Proj_Angle", "equal_angle"],
["E06_Proj_Area", "equal_area"],
[t("E06_Proj_Angle"), "equal_angle"],
[t("E06_Proj_Area"), "equal_area"],
],
projection,
(v) => {
@@ -227,7 +213,7 @@ export function renderE06StabRock(root: HTMLElement): void {
legendSlot.replaceChildren(netLegend(netMode));
if (!shown) {
title.textContent = "";
netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") }));
netSlot.replaceChildren(emptyBox(t("E06_PickStation")));
return;
}
const e = shown.entry;
@@ -247,7 +233,7 @@ export function renderE06StabRock(root: HTMLElement): void {
listSlot.replaceChildren(
stations.length
? buildList(stations, active, (id) => void select(id))
: el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }),
: note(t(result ? "E06_NoRock" : "E06_NoResult")),
);
detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span"));
paintNet();
@@ -346,7 +332,7 @@ export function renderE06StabRock(root: HTMLElement): void {
async function analyze(): Promise<void> {
if (!view?.editable) return;
analyzeButton.disabled = true;
status.textContent = t("E06_Running");
status.replaceChildren(note(t("E06_Running"), "e06-status__text"));
try {
if (dirty && !(await putJoints())) return;
const body = await api<{ status: string; summary?: RockResult["summary"] }>(
@@ -387,10 +373,7 @@ export function renderE06StabRock(root: HTMLElement): void {
await loadResult();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e06-empty",
text: error instanceof Error ? error.message : t("Common_Status_Error"),
}),
emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")),
);
}
})();
+80
View File
@@ -0,0 +1,80 @@
/* =============================================================================
* E06_StabRock_UI_Parts.ts
* E06 안 부품 걸이(553-4) — 부품(보조 글 · 빈 자리 · 표 · 배지 · 분할 고르기)을 부르고
* 촬영 장면 · 시험이 잡는 이름(`e06-hint` · `e06-empty` · `e06-table` · `e06-list` · `e06-cases` · `e06-verdict` · `e06-toggle`)만 얹음.
* 모양은 부품 · 이 파일에 CSS 없음.
* ========================================================================== */
import { createBadge, type BadgeTone } from "@ui/ui_template_badge";
import { createEmpty } from "@ui/ui_template_empty";
import { createSegment } from "@ui/ui_template_segment";
import { createTable, type Cell } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
/** 보조 글 — 걸이 이름(`e06-hint` · `e06-kin` · `e06-status` …) */
export function note(text: string, hook = "e06-hint"): HTMLElement {
const node = createText(text, "muted");
node.classList.add(hook);
return node;
}
export function emptyBox(text: string): HTMLElement {
const node = createEmpty({ text, small: true });
node.classList.add("e06-empty");
return node;
}
export interface E06Table {
root: HTMLElement;
table: HTMLTableElement;
rows: HTMLTableRowElement[];
}
/** 표 부품(데이터표) — `className` = 표에 얹는 걸이 이름 · `role` = data-role */
export function e06Table(
headers: string[],
rows: Cell[][],
className: string,
role?: string,
): E06Table {
const root = createTable(
headers.map((label) => ({ label })),
rows,
{ density: "data" },
);
const table = root.querySelector("table") as HTMLTableElement;
table.classList.add(className);
if (role) table.dataset.role = role;
return { root, table, rows: [...table.tBodies[0].rows] };
}
const TONE: Record<string, BadgeTone> = {
ng: "danger",
check: "warning",
ok: "success",
none: "neutral",
};
/** 판정 램프 — verdict = NG · check · OK · none */
export function verdictLamp(verdict: string, text: string): HTMLElement {
const key = verdict.toLowerCase();
const lamp = createBadge(text, TONE[key] ?? "neutral", { kind: "lamp" });
lamp.classList.add("e06-verdict", `e06-verdict--${key}`);
return lamp;
}
/** 보기 고르기 — 단추 모양은 분할 고르기 부품 · `data-value` 는 그대로 */
export function toggle<T extends string>(
options: [string, T][],
value: T,
onPick: (v: T) => void,
): HTMLElement {
const group = createSegment({
options: options.map(([label, v]) => ({ value: v, label })),
value,
size: 24,
onChange: (v) => onPick(v as T),
});
group.root.classList.add("e06-toggle");
return group.root;
}
+59 -78
View File
@@ -6,6 +6,7 @@
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { e06Table, note, verdictLamp } from "./E06_StabRock_UI_Parts";
export type Verdict = "NG" | "check" | "OK" | "none";
@@ -66,10 +67,7 @@ const fmt = (v: number | null | undefined, d = 2): string =>
v === null || v === undefined ? "–" : v.toFixed(d);
function badge(verdict: Verdict): HTMLElement {
return el("span", {
className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`,
text: t(`E06_V_${verdict}` as LocaleKey),
});
return verdictLamp(verdict, t(`E06_V_${verdict}` as LocaleKey));
}
/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */
@@ -119,7 +117,7 @@ export function buildBand(
el("span", { text: `${hi.toFixed(0)} m` }),
);
box.append(legend, strip, ticks);
} else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") }));
} else box.append(legend, note(t("E06_NoRock")));
return box;
}
@@ -128,34 +126,28 @@ export function buildList(
active: string | null,
onSelect: (id: string) => void,
): HTMLElement {
const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } });
const head = el("tr");
for (const key of [
"E06_L_Station",
"E06_L_Side",
"E06_L_Modes",
"E06_L_MinF",
"E06_L_Verdict",
] as LocaleKey[])
head.append(el("th", { text: t(key) }));
table.append(el("thead", { children: [head] }), el("tbody"));
for (const e of stations) {
const heads = ["E06_L_Station", "E06_L_Side", "E06_L_Modes", "E06_L_MinF", "E06_L_Verdict"];
const list = e06Table(
heads.map((key) => t(key as LocaleKey)),
stations.map((e) => [
e.label ?? e.key,
t(e.slope.side === "left" ? "E06_Left" : "E06_Right"),
`${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}`,
fmt(e.min_F),
badge(e.verdict),
]),
"e06-list",
"station-list",
);
list.rows.forEach((tr, i) => {
const e = stations[i];
const id = entryId(e);
const tr = el("tr", {
className: id === active ? "is-active" : "",
attrs: { "data-id": id, "data-verdict": e.verdict },
});
tr.append(
el("td", { text: e.label ?? e.key }),
el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }),
el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }),
el("td", { text: fmt(e.min_F) }),
el("td", { children: [badge(e.verdict)] }),
);
tr.dataset.id = id;
tr.dataset.verdict = e.verdict;
tr.classList.toggle("is-active", id === active);
tr.addEventListener("click", () => onSelect(id));
table.tBodies[0].append(tr);
}
return table;
});
return list.root;
}
export function buildDetail(e: StationEntry): HTMLElement {
@@ -163,15 +155,14 @@ export function buildDetail(e: StationEntry): HTMLElement {
const s = e.slope;
box.append(
el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }),
el("p", {
className: "e06-hint",
text: t("E06_SlopeLine")
note(
t("E06_SlopeLine")
.replace("{dd}", s.dip_direction.toFixed(0))
.replace("{dip}", s.dip.toFixed(1))
.replace("{h}", s.height_m.toFixed(2))
.replace("{up}", s.upper_dip.toFixed(1))
.replace("{g}", e.gamma_r.toFixed(1)),
}),
),
);
const kin = e.kinematic;
const kinText = [
@@ -179,57 +170,47 @@ export function buildDetail(e: StationEntry): HTMLElement {
`${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`,
`${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`,
];
box.append(el("p", { className: "e06-kin", text: kinText.join("\n") }));
box.append(note(kinText.join("\n"), "e06-kin"));
if (!e.cases.length) {
box.append(
el("p", {
className: "e06-hint",
text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"),
}),
);
box.append(note(t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere")));
return box;
}
const table = el("table", { className: "e06-cases" });
const head = el("tr");
for (const key of [
const heads = [
"E06_C_Mode",
"E06_C_Joints",
"E06_C_Cond",
"E06_C_F",
"E06_C_Req",
"E06_L_Verdict",
] as LocaleKey[])
head.append(el("th", { text: t(key) }));
table.append(el("thead", { children: [head] }), el("tbody"));
for (const c of e.cases) {
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
const note = !c.formed
? t("E06_NotFormed")
: c.small
? t("E06_Small")
: c.persistence_short
? t("E06_Persist")
: "";
const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } });
tr.append(
el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }),
el("td", { text: c.ids.join("·") }),
el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }),
el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }),
el("td", { text: fmt(c.required) }),
el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }),
);
table.tBodies[0].append(tr);
}
box.append(table);
if (e.small_blocks)
box.append(
el("p", {
className: "e06-hint",
text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)),
}),
);
if (kin.toppling.length)
box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") }));
];
const cases = e06Table(
heads.map((key) => t(key as LocaleKey)),
e.cases.map((c) => {
const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK";
const text = !c.formed
? t("E06_NotFormed")
: c.small
? t("E06_Small")
: c.persistence_short
? t("E06_Persist")
: "";
return [
t(`E06_Mode_${c.mode}` as LocaleKey),
c.ids.join("·"),
t(`E06_Cond_${c.condition}` as LocaleKey),
`${fmt(c.F, 3)}${text ? ` ${text}` : ""}`,
fmt(c.required),
c.small ? "–" : badge(verdict),
];
}),
"e06-cases",
);
cases.rows.forEach((tr, i) => {
tr.dataset.mode = e.cases[i].mode;
tr.dataset.cond = e.cases[i].condition;
});
box.append(cases.root);
if (e.small_blocks) box.append(note(t("E06_SmallCount").replace("{n}", String(e.small_blocks))));
if (kin.toppling.length) box.append(note(t("E06_ToppleNote")));
return box;
}
+21 -10
View File
@@ -6,7 +6,7 @@
* 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createLegend } from "@ui/ui_template_flow_legend";
import { t, type LocaleKey } from "@ui/ui_template_locale";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -169,8 +169,19 @@ export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVG
return root;
}
/** 범례 — 범례 부품(선 · 점선 · 칸 · 점) · 삼각(교선)만 걸이 모양 */
const LEGEND_MARK: Record<string, { color: string; kind: "chip" | "line" | "dash" | "dot" }> = {
slope: { color: "var(--color-text)", kind: "line" },
friction: { color: "var(--color-text-secondary)", kind: "dash" },
planar: { color: "color-mix(in srgb, var(--e06-planar) 35%, transparent)", kind: "chip" },
daylight: { color: "var(--e06-planar)", kind: "dash" },
wedge: { color: "color-mix(in srgb, var(--e06-wedge) 35%, transparent)", kind: "chip" },
line: { color: "var(--e06-wedge)", kind: "chip" },
toppling: { color: "color-mix(in srgb, var(--e06-toppling) 35%, transparent)", kind: "chip" },
pole: { color: "var(--e06-pole)", kind: "dot" },
};
export function netLegend(mode: NetMode): HTMLElement {
const box = el("div", { className: "e06-legend" });
const items: [string, LocaleKey][] = [
["slope", "E06_Lg_Slope"],
["friction", "E06_Lg_Friction"],
@@ -181,13 +192,13 @@ export function netLegend(mode: NetMode): HTMLElement {
items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]);
if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]);
if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]);
for (const [cls, key] of items) {
const row = el("span", { className: "e06-legend__row" });
row.append(
el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }),
el("span", { text: t(key) }),
);
box.append(row);
}
const box = createLegend(
items.map(([cls, key]) => ({ label: t(key), ...LEGEND_MARK[cls] })),
{ vertical: true },
);
box.classList.add("e06-legend");
box.querySelectorAll(".ui-legend__mark").forEach((mark, i) => {
if (items[i][0] === "line") mark.classList.add("e06-legend__mark--line");
});
return box;
}
+12 -167
View File
@@ -12,6 +12,7 @@
--e06-planar: #d03b3b;
--e06-wedge: #f1963b;
--e06-toppling: #9b6dde;
--e06-pole: #2f6fd6;
height: 100dvh;
box-sizing: border-box;
}
@@ -31,39 +32,21 @@
overflow: auto;
}
.e06-empty,
/* 보조 글 · 빈 자리 · 띠 알림 · 표 · 설정 줄 · 판정 램프는 부품 — 여기는 자리만 */
.e06-status__text,
.e06-hint,
.e06-status,
.e06-kin {
margin: 0;
white-space: pre-line;
color: var(--color-text-muted);
font-size: 12px;
}
.e06-empty {
padding: var(--spacing-16);
}
.e06-status.is-error {
color: var(--color-danger);
}
.e06-kin {
color: var(--color-text);
margin: var(--spacing-16);
}
.e06-panel .ui-sidebar-actions {
flex-wrap: wrap;
}
.e06-stale {
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
color: var(--color-text);
}
.e06-stale[hidden] {
display: none;
}
@@ -73,32 +56,8 @@
overflow-x: auto;
}
.e06-table {
border-collapse: collapse;
font-size: 12px;
}
.e06-table th {
padding: 2px 3px;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
white-space: nowrap;
}
.e06-table td {
padding: 1px 2px;
}
.e06-input {
width: 4.2em;
padding: 2px 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 12px;
box-sizing: border-box;
}
.e06-input--name {
@@ -109,11 +68,6 @@ select.e06-input {
width: auto;
}
.e06-input.is-invalid {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface));
}
.e06-row-actions {
display: flex;
justify-content: flex-end;
@@ -152,17 +106,6 @@ select.e06-input {
color: var(--color-text);
}
.e06-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e06-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e06-stage {
display: flex;
gap: var(--spacing-12);
@@ -289,58 +232,9 @@ select.e06-input {
color: #0e7490;
}
.e06-legend {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--color-text);
}
.e06-legend__row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.e06-legend__mark {
display: inline-block;
width: 16px;
height: 10px;
border-radius: 2px;
}
.e06-legend__mark--slope {
height: 3px;
background: var(--color-text);
}
.e06-legend__mark--friction {
height: 0;
border-top: 1px dashed var(--color-text-secondary);
}
.e06-legend__mark--daylight {
height: 0;
border-top: 1px dashed var(--e06-planar);
}
.e06-legend__mark--planar {
background: color-mix(in srgb, var(--e06-planar) 35%, transparent);
}
.e06-legend__mark--wedge {
background: color-mix(in srgb, var(--e06-wedge) 35%, transparent);
}
.e06-legend__mark--toppling {
background: color-mix(in srgb, var(--e06-toppling) 35%, transparent);
}
.e06-legend__mark--line {
width: 10px;
background: var(--e06-wedge);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.e06-legend__mark--pole {
width: 10px;
border-radius: 50%;
background: #2f6fd6;
}
/* ---- 위험 띠 ---- */
.e06-band {
@@ -418,74 +312,25 @@ select.e06-input {
overflow: auto;
}
.e06-list,
.e06-cases {
width: 100%;
border-collapse: collapse;
font-size: 12px;
color: var(--color-text);
.e06-list-slot .ui-table-wrap {
overflow: visible;
border: 0;
border-radius: 0;
background: none;
}
.e06-list th,
.e06-cases th {
.e06-list-slot th {
position: sticky;
top: 0;
padding: 3px 4px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
text-align: left;
}
.e06-list td,
.e06-cases td {
padding: 3px 4px;
border-top: 1px solid var(--color-border);
z-index: 1;
background: var(--color-surface-raised);
}
.e06-list tbody tr {
cursor: pointer;
}
.e06-list tbody tr:hover {
background: var(--color-surface-raised);
}
.e06-list tr.is-active {
background: color-mix(in srgb, var(--color-primary) 16%, transparent);
}
.e06-detail h4 {
margin: 0 0 4px;
color: var(--color-text);
}
.e06-verdict {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.e06-verdict::before {
content: "";
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--e06-none);
}
.e06-verdict--ng::before {
background: var(--e06-ng);
border-radius: 2px;
transform: rotate(45deg);
}
.e06-verdict--ok::before {
background: var(--e06-ok);
}
.e06-verdict--check::before {
background: var(--e06-check);
border-radius: 0;
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
+114 -117
View File
@@ -18,8 +18,17 @@ import {
showToast,
type InputFieldHandle,
} from "@ui/ui_template_elements";
import { createBanner } from "@ui/ui_template_banner";
import { createEmpty } from "@ui/ui_template_empty";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createLegend } from "@ui/ui_template_flow_legend";
import { createKv } from "@ui/ui_template_kv";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { createProgressBar } from "@ui/ui_template_progress";
import { createSegment } from "@ui/ui_template_segment";
import { createTable } from "@ui/ui_template_table";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
@@ -107,11 +116,26 @@ const SCENARIOS = ["sinmap", "saturated"];
const scenarioName = (value: unknown): string =>
SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? "");
/** 안내 글 한 줄 — 걸이 이름(`e07-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("e07-empty");
return node;
}
/** 지도 범례 — 떠 있는 판(머리 = 제목) 안에 범례 부품 */
function legend(layer: MapLayer, scenario: unknown): HTMLElement {
const box = el("div", { className: "e07-legend" });
box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") }));
if (scenario)
box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) }));
const panel = createFloatPanel({
title: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS"),
});
panel.root.classList.add("e07-legend");
if (scenario) panel.body.append(note(scenarioName(scenario), "e07-legend__sub"));
const rows =
layer === "cls"
? CLASS_KEYS.map((k, i) => ({
@@ -127,12 +151,8 @@ function legend(layer: MapLayer, scenario: unknown): HTMLElement {
? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}`
: `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`,
}));
for (const row of rows) {
const item = el("span", { className: "e07-legend__row" });
item.append(swatch(row.color), el("span", { text: row.label }));
box.append(item);
}
return box;
panel.body.append(createLegend(rows, { vertical: true }));
return panel.root;
}
export function renderE07StabArea(root: HTMLElement): void {
@@ -140,8 +160,7 @@ export function renderE07StabArea(root: HTMLElement): void {
const panel = el("div", { className: "ui-sidebar-panel e07-panel" });
const main = el("div", { className: "e07-page" });
mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") }));
if (!projectId) return void main.append(emptyBox(t("E07_NoProject")));
// ---- 가운데 ----
const map = createAreaMap();
@@ -150,25 +169,21 @@ export function renderE07StabArea(root: HTMLElement): void {
const legendSlot = el("div", { className: "e07-legend-slot" });
const paintLegend = (): void =>
legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario));
const toggle = el("div", { className: "e07-toggle" });
const layerButtons = (
[
["E07_Layer_Class", "cls"],
["E07_Layer_FS", "fs"],
] as [LocaleKey, MapLayer][]
).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", () => {
layerButtons.forEach((b) => b.classList.toggle("is-on", b === button));
layer = value;
const layerToggle = createSegment({
options: [
{ value: "cls", label: t("E07_Layer_Class") },
{ value: "fs", label: t("E07_Layer_FS") },
],
value: "cls",
size: 24,
onChange: (value) => {
layer = value as MapLayer;
map.setLayer(layer);
paintLegend();
});
return button;
},
});
toggle.append(...layerButtons);
const toggle = layerToggle.root;
toggle.classList.add("e07-toggle");
const stage = el("div", { className: "e07-stage" });
stage.append(map.root, legendSlot);
const center = el("div", { className: "e07-center" });
@@ -203,7 +218,7 @@ export function renderE07StabArea(root: HTMLElement): void {
});
cards.soil.append(material.root);
cards.rain.append(scenario.root);
const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") });
const sourceHint = note(t("E07_Defaults_Source"), "e07-hint");
for (const [key, label, card] of NUMBER_FIELDS) {
const field = createInputField({ label: t(label), type: "number", compact: true });
field.input.step = "any";
@@ -211,10 +226,10 @@ export function renderE07StabArea(root: HTMLElement): void {
fields.set(key, field);
cards[card].append(field.root);
}
const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } });
const progressText = el("p", { className: "e07-progress__text" });
const progressBar = createProgressBar();
const progressText = note("", "e07-progress__text");
const progressBox = el("div", { className: "e07-progress-box" });
progressBox.append(progressBar, progressText);
progressBox.append(progressBar.root, progressText);
progressBox.hidden = true;
const defaultsButton = createButton({
label: t("E07_Defaults"),
@@ -233,10 +248,10 @@ export function renderE07StabArea(root: HTMLElement): void {
});
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(defaultsButton, reanalyzeButton, analyzeButton);
const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") });
const readOnly = note(t("E07_ReadOnly"), "e07-hint");
readOnly.hidden = true;
panel.append(
el("p", { className: "e07-hint", text: t("E07_Hint") }),
note(t("E07_Hint"), "e07-hint"),
readOnly,
cards.soil,
cards.rain,
@@ -304,11 +319,10 @@ export function renderE07StabArea(root: HTMLElement): void {
function paintResult(): void {
const result = overview?.result;
if (!result) {
resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") }));
resultBody.replaceChildren(emptyBox(t("E07_Result_None")));
return;
}
const s = result.summary;
const list = el("dl", { className: "e07-summary" });
const pairs: [LocaleKey, string][] = [
["E07_Scenario", scenarioName(result.settings.scenario)],
["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)],
@@ -326,88 +340,76 @@ export function renderE07StabArea(root: HTMLElement): void {
`${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`,
],
];
for (const [key, value] of pairs)
list.append(
el("dt", { text: t(key) }),
el("dd", { text: value, attrs: { "data-field": key } }),
);
const areas = el("table", { className: "e07-table e07-areas" });
const list = createKv(pairs.map(([key, value]) => ({ label: t(key), value })));
list.classList.add("e07-summary");
list.querySelectorAll("dd").forEach((dd, i) => dd.setAttribute("data-field", pairs[i][0]));
const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1;
areas.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Class") }),
el("th", { text: t("E07_Col_Area") }),
el("th", { text: "%" }),
],
}),
...CLASS_KEYS.map((k, i) => {
const name = el("td");
name.append(
swatch(CLASS_COLORS[i]),
el("span", { text: t(`E07_Class_${k}` as LocaleKey) }),
);
return el("tr", {
attrs: { "data-class": k },
children: [
name,
el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }),
el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }),
],
});
}),
/** 색 칩 + 글 한 칸 */
const chipCell = (color: string, text: string): Node => {
const cell = document.createDocumentFragment();
cell.append(swatch(color), el("span", { text }));
return cell;
};
const areas = createTable(
[
{ label: t("E07_Col_Class") },
{ label: t("E07_Col_Area"), align: "end" },
{ label: "%", align: "end" },
],
CLASS_KEYS.map((k, i) => [
chipCell(CLASS_COLORS[i], t(`E07_Class_${k}` as LocaleKey)),
Math.round(s.areas_m2[k] ?? 0).toLocaleString(),
(((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1),
]),
{ density: "data" },
);
const table = el("table", { className: "e07-table e07-stations" });
table.append(
el("tr", {
children: [
el("th", { text: t("E07_Col_Station") }),
el("th", { text: t("E07_Col_FsMin") }),
el("th", { text: t("E07_Col_Ratio") }),
el("th", { text: t("E07_Col_Level") }),
el("th", { text: t("E07_Col_RiskArea") }),
],
}),
...result.stations.map((r) => {
const level = el("td");
if (r.level)
level.append(
swatch(LEVEL_COLORS[r.level]),
el("span", { text: t(`E07_Level_${r.level}` as LocaleKey) }),
);
const tr = el("tr", {
className: r.level === "high" ? "is-risk" : "",
attrs: { "data-chainage": String(r.chainage_m) },
children: [
el("td", { text: shortLabel(r.label, r.chainage_m) }),
el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }),
el("td", {
text: r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
}),
level,
el("td", {
text:
r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
}),
],
});
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on"));
tr.classList.add("is-on");
map.focus(r.chainage_m);
});
return tr;
}),
areas.querySelector("table")!.classList.add("e07-table", "e07-areas");
areas
.querySelectorAll("tbody tr")
.forEach((tr, i) => ((tr as HTMLElement).dataset.class = CLASS_KEYS[i]));
const stations = createTable(
[
{ label: t("E07_Col_Station") },
{ label: t("E07_Col_FsMin"), align: "end" },
{ label: t("E07_Col_Ratio"), align: "end" },
{ label: t("E07_Col_Level") },
{ label: t("E07_Col_RiskArea"), align: "end" },
],
result.stations.map((r) => [
shortLabel(r.label, r.chainage_m),
r.fs_min === undefined ? "-" : r.fs_min.toFixed(2),
r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "",
r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(),
]),
{ density: "data" },
);
const table = stations.querySelector("table")!;
table.classList.add("e07-table", "e07-stations");
table.querySelectorAll("tbody tr").forEach((row, i) => {
const tr = row as HTMLElement;
const r = result.stations[i];
tr.dataset.chainage = String(r.chainage_m);
tr.classList.toggle("is-risk", r.level === "high");
tr.addEventListener("click", () => {
table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on", "is-active"));
tr.classList.add("is-on", "is-active");
map.focus(r.chainage_m);
});
});
const notes: HTMLElement[] = [];
if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") }));
notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") }));
if (result.stale) {
const warn = createBanner({ tone: "warning", text: t("E07_Result_Stale") });
warn.classList.add("e07-warn");
notes.push(warn);
}
notes.push(note(t("E07_Result_Note"), "e07-note"));
resultBody.replaceChildren(
list,
el("h4", { text: t("E07_Areas_Title") }),
areas,
el("h4", { text: t("E07_Stations_Title") }),
table,
stations,
...notes,
);
}
@@ -430,8 +432,8 @@ export function renderE07StabArea(root: HTMLElement): void {
if (!p) break;
running = p.running;
progressBox.hidden = !p.running;
progressBar.value = p.percent;
progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`;
progressBar.set(p.percent / 100);
progressText.replaceChildren(`${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`);
paintButtons();
if (!p.running) {
if (p.error) showToast(p.error, "error");
@@ -469,12 +471,7 @@ export function renderE07StabArea(root: HTMLElement): void {
try {
await refresh();
} catch (error) {
main.replaceChildren(
el("p", {
className: "e07-empty",
text: error instanceof Error ? error.message : String(error),
}),
);
main.replaceChildren(emptyBox(error instanceof Error ? error.message : String(error)));
return;
}
const ov = overview!;
+8 -95
View File
@@ -23,17 +23,6 @@
box-sizing: border-box;
}
.e07-toggle {
display: inline-flex;
gap: var(--spacing-4);
}
.e07-toggle .ui-btn.is-on {
border-color: var(--color-primary);
background: var(--color-primary);
color: var(--color-primary-text);
}
.e07-stage {
position: relative;
flex: 1 1 auto;
@@ -77,6 +66,7 @@
pointer-events: none;
}
/* 판 · 범례 · 값 목록 · 표 · 띠 알림 · 진행 막대는 부품 — 여기는 자리만 */
.e07-legend-slot {
position: absolute;
left: var(--spacing-8);
@@ -84,27 +74,12 @@
pointer-events: none;
}
.e07-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;
.e07-legend-slot .e07-legend {
position: static;
}
.e07-legend__sub {
max-width: 180px;
color: var(--color-text-muted);
}
.e07-legend__row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.e07-chip {
@@ -121,10 +96,7 @@
/* 좌측 */
.e07-hint,
.e07-note {
margin: 0 0 var(--spacing-8);
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.5;
margin-bottom: var(--spacing-8);
}
.e07-panel .ui-field[hidden] {
@@ -142,16 +114,6 @@
display: none;
}
.e07-progress {
width: 100%;
}
.e07-progress__text {
margin: 0;
color: var(--color-text);
font-size: 12px;
}
/* 우측 */
.e07-result {
padding: var(--spacing-12);
@@ -163,74 +125,25 @@
font-size: 13px;
}
.e07-summary {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-8) var(--spacing-12);
margin: 0;
font-size: 13px;
}
.e07-summary dt {
color: var(--color-text-muted);
}
.e07-summary dd {
margin: 0;
font-weight: 600;
text-align: right;
}
.e07-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.e07-table th,
.e07-table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: right;
.e07-result .ui-table th,
.e07-result .ui-table td {
padding: 0 4px;
white-space: nowrap;
}
.e07-table th:first-child,
.e07-table td:first-child,
.e07-stations td:nth-child(4) {
text-align: left;
}
.e07-table th {
color: var(--color-text-muted);
font-weight: 600;
}
.e07-stations tr[data-chainage] {
cursor: pointer;
}
.e07-stations tr[data-chainage]:hover {
background: var(--color-surface-raised);
}
.e07-stations tr.is-risk td {
color: var(--color-danger);
font-weight: 600;
}
.e07-stations tr.is-on {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.e07-warn {
margin: var(--spacing-12) 0 0;
color: var(--color-danger);
font-size: 12px;
margin-top: var(--spacing-12);
}
.e07-empty {
margin: var(--spacing-16);
color: var(--color-text-muted);
}
@@ -87,6 +87,19 @@ let layoutOpts = null;
let confirmAnswer = false;
const store = new Map(); // 가짜 세션 캐시
const common = {
"../A00_Common/b_page_tabs": {
// 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름
fillTabs: (host, o) => {
const kids = o.tabs.map((t) =>
Object.assign(new Node("button"), {
onClick: () => t.key !== o.value && o.onChange(t.key),
dataset: { [o.data]: t.key },
}),
);
host.replaceChildren(...kids);
return kids;
},
},
"@ui/ui_template_workflow_layout": {
createWorkflowLayout: (opts) => {
layoutOpts = opts;
@@ -445,11 +445,16 @@ const out = {};
// ── 3. 단추 줄(318-1) — 빈자리 누름 = 카드 고름 · 조작 누름 = 안 넘김 ───────────
{
// 단추 줄 = 부품 단추 · 분할 · 켜기(553-2) — 진짜 부품 파일을 흉내 노드로 돌림
const UI = (name) => path.join(ROOT, "ui_template", `${name}.ts`);
const parts = ["ui_template_button", "ui_template_segment", "ui_template_toggle"];
const loadUi = makeLoader([...parts, "ui_template_elements_base"].map(UI));
const load = makeLoader([B06("B06_Section_UI_Cross_Design")], {
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }),
},
...Object.fromEntries(parts.map((name) => [`@ui/${name}`, loadUi(UI(name))])),
});
const { buildDesignControls } = load(B06("B06_Section_UI_Cross_Design"));
const { bar } = buildDesignControls(
@@ -155,10 +155,13 @@ const walk = (node) => {
walk(groundSegment);
walk(bar);
const press = (pick) => buttons.find(pick)?.listeners.click?.forEach((fn) => fn({}));
// 부품 켜기 칩(553-2) — 글은 안쪽 span 에 · 자식 글을 이어 읽음
const text = (node) =>
typeof node === "string"
? node
: (node?.textContent ?? "") + (node?.children ?? []).map(text).join("");
out.soilPressed = buttons[0]?.getAttribute("aria-pressed");
press(
(b) => b.textContent === "측구" || b.textContent === "측구 없음" || /측구/.test(b.textContent),
);
press((b) => /측구/.test(text(b)));
out.ditchChange = sent.at(-1) ?? null;
// 새 카드 · 토사 단추 — 지반만(측구형식은 이미 일반이라 안 실림)
buttons.length = 0;
@@ -81,6 +81,19 @@ const calls = [];
let layoutOpts = null;
const store = new Map(); // 가짜 세션 캐시 — 이름 → 값
const common = {
"../A00_Common/b_page_tabs": {
// 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름
fillTabs: (host, o) => {
const kids = o.tabs.map((t) =>
Object.assign(new Node("button"), {
onClick: () => t.key !== o.value && o.onChange(t.key),
dataset: { [o.data]: t.key },
}),
);
host.replaceChildren(...kids);
return kids;
},
},
"@ui/ui_template_workflow_layout": {
createWorkflowLayout: (opts) => {
layoutOpts = opts;
+45 -1
View File
@@ -15,7 +15,7 @@ COMMON = "ui_template/ui_template_group_cards.ts"
TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts"
GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts"
# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝
# 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝
STUBS = r"""
const kept = new Map();
const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null);
@@ -27,6 +27,50 @@ const createButton = (o) => {
if (o.onClick) b.addEventListener("click", o.onClick);
return b;
};
const fillTabs = (host, o) => {
const kids = o.tabs.map((t) => {
const b = document.createElement("button");
b.textContent = t.label;
b.dataset[o.data] = t.key;
b.addEventListener("click", () => t.key !== o.value && o.onChange(t.key));
return b;
});
host.replaceChildren(...kids);
return kids;
};
const createSegment = (o) => {
const root = document.createElement("div");
let current = o.value;
const items = o.options.map((x) => {
const b = document.createElement("button");
b.textContent = x.label;
b.dataset.value = x.value;
b.addEventListener("click", () => {
if (current === x.value) return;
current = x.value;
mark();
o.onChange(x.value);
});
return b;
});
const mark = () => items.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.value === current)));
mark();
root.append(...items);
return { root, get: () => current, set: (v) => { current = v; mark(); } };
};
const createText = (text, kind) => {
const p = document.createElement("p");
p.className = `ui-text ui-text--${kind}`;
p.textContent = text;
return p;
};
const createBadge = (text, tone) => {
const span = document.createElement("span");
span.className = `ui-badge ui-badge--${tone}`;
span.textContent = text;
return span;
};
const createToolbar = () => document.createElement("div");
const hoverCalls = [];
const attachHoverInfo = (root, text, selector) => { hoverCalls.push({ root, text, selector }); return () => {}; };
"""
@@ -48,6 +48,7 @@ function createButton(o) {
b.addEventListener("click", o.onClick);
return b;
}
function createText(text) { const p = new FakeEl("p"); p.textContent = text; return p; }
function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); }
let user = {};
@@ -150,11 +150,13 @@ def test_B08_표_카드_차례_표_아래줄_안내():
def test_B08_자재_구조물_안내_줄_표_아래():
material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8")
assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material
assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래)
assert (
'createToolbar({ items: ["spacer", save], size: 32 })' in material
) # 저장 단추만 위 줄(안내는 아래)
summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8")
assert "createCard({ body: [table, ...parts] })" in summary
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8")
assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar")
assert "margin-bottom" in _block(css, ".b08-material__toolbar") # 줄 모양 = 부품 도구줄(spacer)
# ── 창 끝까지 셈(199-1)에 아래 줄 몫 ────────────────────────────────────────
+1 -1
View File
@@ -161,7 +161,7 @@ def test_메인_탭_하위_탭_추가_삭제():
"ui_template/ui_template_locale_b4.ts"
)
ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts")
assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui
assert '{ key: "table", label: "거리이정표" }' in ui and "...routes.map((r, i) => ({" in ui
# 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만)
assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui
modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts")
@@ -172,6 +172,8 @@ def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화():
assert 'label: "그림 변경"' in route and 'label: "초기화"' in route
assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1]
assert "map.root.hidden = user;" in route and "editMap({" in route # 그림 없으면 길찾기 지도
assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css
assert (
'createToolbar({ items: [title, "spacer", ...buttons], size: 32 })' in route
) # 머리 줄 = 부품 도구줄
state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts")
assert "export const hasUserImage" in state and "canRevertImage" not in state
+7 -3
View File
@@ -434,9 +434,13 @@ def test_좌측_하단_두_줄_가로_넘침_없음():
assert "files.append(pdfBtn, xlsxBtn)" in page
assert "nav.dataset.helpRow" in page and "nav.append(back)" in page
assert "actions.append(files, nav)" in page
css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css"))
assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css
btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :]
# 553-3 — 좌측 하단 줄 = B09 dock 한 벌(복사 없음) · 줄 폭을 나눠 가짐 · 글 줄임표
assert 'actions.className = "ui-sidebar-actions b09-dock b09-print__actions"' in page
assert 'className = "b09-dock__row b09-print__row"' in page
css = re.sub(r"\s+", " ", _text(ROOT / "B09_Estimation/B09_Estimation_UI_Page.css"))
assert ".b09-dock.ui-sidebar-actions { flex-direction: column; }" in css
btn = css[css.index(".b09-dock .b09-dock__row > .ui-btn:not(") :]
btn = btn[: btn.index("}")]
# 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표
assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn
assert ".b09-print__actions" not in _text(DRAFT / "B09_Draft_Print_Page.css")
@@ -78,7 +78,7 @@ def test_좌측_스위치_누름():
"\nlet redraws = 0;"
"\nconst dock = createDock([document.createElement('button')], () => redraws++);"
"\nconst row = dock.children[0];"
"\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);"
"\nconst pressed = () => row.children.map((b) => b.attrs['aria-checked']);"
"\nconst labels = row.children.map((b) => b.textContent);"
"\nconst a = pressed();"
"\nrow.children[0].click();" # 이미 박스 = 그대로
+57
View File
@@ -145,3 +145,60 @@ def test_e05_parts():
assert ".e05-stale[hidden]" in css # 띠 알림은 flex 라 hidden 을 덮음
for dead in (".e05-preview {", ".e05-preview__head", ".e05-status.is-error"):
assert dead not in css, dead
def test_e06_parts():
names = ["Joints", "Page", "Result", "Stereonet"]
ts = {n: _src(f"E06_StabRock/E06_StabRock_UI_{n}.ts") for n in names}
parts = _src("E06_StabRock/E06_StabRock_UI_Parts.ts")
css = _src("E06_StabRock/E06_StabRock_UI_Style.css")
for n, src in ts.items():
assert 'el("table"' not in src and 'el("input"' not in src, n
assert 'el("select"' not in src and 'el("p"' not in src, n
for part in ("createTable(", "createBadge(", "createSegment(", "createText(", "createEmpty("):
assert part in parts, part
assert "createLegend(" in ts["Stereonet"] and "createBanner(" in ts["Page"]
assert "createInputField(" in ts["Joints"] and "createSelectField(" in ts["Joints"]
assert (
'classList.add("e06-toggle")' in parts
) # 촬영 장면이 `.e06-toggle button[data-value]` 로 잡음
assert '"station-list"' in ts["Result"] # 촬영 장면 · 시험이 잡는 data-role
for dead in (
".e06-verdict",
".e06-list th",
".e06-cases",
".e06-table th",
".e06-legend__row",
".e06-toggle .ui-btn",
"is-invalid",
):
assert dead not in css, dead
assert "is-invalid" not in ts["Joints"] + ts["Page"]
def test_e07_parts():
page = _src("E07_StabArea/E07_StabArea_UI_Page.ts")
css = _src("E07_StabArea/E07_StabArea_UI_Style.css")
assert 'el("table"' not in page and 'el("progress"' not in page and 'el("p"' not in page
for part in (
"createSegment(",
"createFloatPanel(",
"createLegend(",
"createKv(",
"createTable(",
):
assert part in page, part
for part in ("createProgressBar()", "createBanner(", "createEmpty("):
assert part in page, part
assert (
'toggle.classList.add("e07-toggle")' in page
) # 촬영 장면이 `.e07-toggle button[data-value]` 로 잡음
assert "data-chainage" in css or "dataset.chainage" in page # 줄 누름 = 지도 이동
for dead in (
".e07-toggle .ui-btn",
".e07-legend__row",
".e07-summary",
".e07-table th",
".e07-progress {",
):
assert dead not in css, dead
+1
View File
@@ -96,6 +96,7 @@
gap: var(--spacing-8);
}
/* 단추 = 단추 부품(단 24) · 여기는 넷이 폭을 나눔만 */
.cad-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
+5 -2
View File
@@ -1,4 +1,5 @@
/* ui_template/cad_host/cad_host_tabs — CAD 를 감싸는 공용 탭 줄(82-1 · M02 구조물 도면 · B07). */
/* ui_template/cad_host/cad_host_tabs — CAD 를 감싸는 공용 탭 줄(82-1 · M02 구조물 도면 · B07).
문서 탭 = 꼴 그대로 · 공통 값 · 크기 단만(탭 24 · × 18 · + 22 = 아이콘 단추 부품 · 553-6). */
.cad-tabs {
display: flex;
@@ -18,8 +19,10 @@
flex: 0 0 auto;
align-items: center;
gap: 4px;
box-sizing: border-box;
max-width: 220px;
padding: 4px 8px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--color-border);
border-bottom: none;
border-radius: 6px 6px 0 0;