feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로

- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄
- M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음
- 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로)
- 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음)
- test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 11:46:28 +09:00
co-authored by Claude Sonnet 5.5
parent 771e89c983
commit eaadb48a2b
15 changed files with 1973 additions and 12 deletions
+324
View File
@@ -0,0 +1,324 @@
/* 수식(796-2) — 분수 · 첨자 · 괄호 · 줄 · 변수 풀이 · 블록 · 색은 공통 값만 */
.ui-eq {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
container-type: inline-size;
font-size: var(--text-body);
color: var(--color-text-body);
}
/* ── 식 그림 ── */
.ui-eq__expr,
.ui-eq__body {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0 var(--spacing-4);
min-width: 0;
}
.ui-eq__num,
.ui-eq__str {
font-variant-numeric: tabular-nums;
}
.ui-eq__str {
color: var(--color-text-secondary);
}
.ui-eq__op {
color: var(--color-text-secondary);
padding: 0 1px;
}
.ui-eq__sym.is-latin {
font-style: italic;
font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif;
}
.ui-eq__script {
font-size: 0.7em;
line-height: 0;
font-style: normal;
}
.ui-eq__var {
border-radius: var(--radius-sm);
}
.ui-eq__var.is-pick,
.ui-eq__block.is-pick {
cursor: pointer;
}
.ui-eq__var.is-pick:hover,
.ui-eq__var.is-pick:focus-visible {
outline: 1px solid var(--color-accent);
background: var(--color-accent-soft);
}
/* 분수 — 가로 줄 안에 위 · 아래를 세로로 쌓고 사이에 선 */
.ui-eq__frac {
display: inline-flex;
flex-direction: column;
align-items: center;
vertical-align: middle;
min-width: 0;
max-width: 100%;
}
.ui-eq__top {
padding: 0 var(--spacing-4) 2px;
border-bottom: 1px solid currentcolor;
text-align: center;
}
.ui-eq__bottom {
padding: 2px var(--spacing-4) 0;
text-align: center;
}
/* 괄호 — 안 키에 맞춰 늘어나는 곡선 */
.ui-eq__paren {
display: inline-flex;
align-items: stretch;
gap: 2px;
}
.ui-eq__paren::before,
.ui-eq__paren::after {
content: "";
flex: none;
width: 5px;
border: 1.5px solid currentcolor;
}
.ui-eq__paren::before {
border-right: 0;
border-radius: 8px 0 0 8px;
}
.ui-eq__paren::after {
border-left: 0;
border-radius: 0 8px 8px 0;
}
.ui-eq__inner {
display: inline-flex;
align-items: center;
min-width: 0;
}
.ui-eq__fn {
color: var(--color-text-secondary);
}
/* 조건 칩 · 하위 로직 칩 */
.ui-eq__cond,
.ui-eq__block {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-pills);
background: var(--color-surface);
font-size: var(--text-body-sm);
max-width: 100%;
}
.ui-eq__cond-key {
color: var(--color-warning);
font-weight: var(--font-weight-medium);
}
.ui-eq__block {
border-color: var(--color-info);
}
.ui-eq__block.is-pick:hover {
background: var(--color-accent-soft);
}
.ui-eq__badge {
color: var(--color-info);
}
.ui-eq__block-name {
max-width: 14em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-eq__block-val {
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-medium);
}
/* ── 줄 ── */
.ui-eq__line {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
min-width: 0;
}
.ui-eq__line[data-selected] {
box-shadow: inset 3px 0 0 var(--color-accent);
background: var(--color-accent-soft);
}
.ui-eq__line.is-pick > .ui-eq__main {
cursor: pointer;
}
.ui-eq__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
gap: var(--spacing-12);
align-items: start;
}
.ui-eq__step {
align-self: flex-start;
min-width: 1.4em;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
.ui-eq__box {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
max-width: 100%;
min-width: 0;
}
.ui-eq__res {
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
.ui-eq__unit {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-regular);
}
.ui-eq__raw {
font-family: var(--font-mono, monospace);
font-size: var(--text-body-sm);
overflow-wrap: anywhere;
}
.ui-eq__bad {
color: var(--color-danger);
font-size: var(--text-caption);
}
/* 변수 풀이 */
.ui-eq__solve {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-eq__solve-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 var(--spacing-4);
min-width: 0;
}
.ui-eq__solve-row.is-pick {
cursor: pointer;
}
.ui-eq__solve-row.is-pick:hover {
background: var(--color-accent-soft);
}
.ui-eq__solve-name,
.ui-eq__solve-val {
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-eq__solve-val {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.ui-eq__solve-src {
color: var(--color-text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 하위 로직 블록 — 접힘 */
.ui-eq__blockpanel {
margin-left: var(--spacing-16);
padding-left: var(--spacing-8);
border-left: 2px solid var(--color-info);
}
.ui-eq__block-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.ui-eq__blockpanel[data-open] .ui-eq__block-head .ui-btn__icon {
transform: rotate(90deg);
}
.ui-eq__bundle {
display: inline-flex;
flex-wrap: wrap;
gap: var(--spacing-8);
font-size: var(--text-caption);
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.ui-eq__block-key {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__block-body {
margin-top: var(--spacing-4);
}
.ui-eq__block-body .ui-eq__line {
padding: var(--spacing-4);
}
@container (max-width: 640px) {
.ui-eq__main {
grid-template-columns: minmax(0, 1fr);
}
}
:is(.ui-eq__block-body, .ui-eq__bad)[hidden] {
display: none !important;
}
+279
View File
@@ -0,0 +1,279 @@
/* =============================================================================
* ui_template_equation.ts
* 부품 「수식」(796-2 · 요구 655) — 수식 나무(서버 `master_equation.py` 나무 JSON)를 PPT 수식 꼴로 그림.
* 꼴 = 분수(가로 줄 안 세로 쌓기 · 분수선) · 위 · 아래첨자 · × ÷ + − · 비교 · 괄호(키 큰 곡선) · % · 단위 ·
* 조건 칩(만약 → 걸린 쪽만) · 함수 · 하위 로직 칩(블록 · 접힘은 `ui_template_equation_line.ts`)
* 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄
* 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만
* 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts`
* 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠)
* ========================================================================== */
import "./ui_template_equation.css";
/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */
export interface EqNode {
꼴: string;
글?: string;
따옴?: boolean;
값?: unknown;
열쇠?: string;
이름?: string;
기호?: string;
위?: string | EqNode;
아래?: string | EqNode;
단위?: string;
갈래?: string;
식?: string;
칸?: string | null;
표?: string;
표이름?: string;
행?: Record<string, unknown>;
좌?: EqNode;
우?: EqNode;
안?: EqNode;
조건?: EqNode;
참?: EqNode;
거짓?: EqNode;
걸림?: number;
이름식?: EqNode;
인자?: EqNode[];
묶음?: Record<string, unknown> | null;
결과단위?: string;
나무?: EqTree | null;
사슬?: boolean;
}
/** 로직 하나의 수식(줄 · 변수표 · 합계) */
export interface EqTree {
열쇠: string;
이름: string;
결과단위: string;
줄: EqLine[];
변수표: EqVarRow[];
합계: Record<string, unknown>;
}
export interface EqVarRow {
열쇠: string;
이름: string;
기호: string;
위: string;
아래: string;
단위: string;
갈래: string;
기본값: unknown;
설명: string;
표시?: number;
}
export interface EqLine {
갈래: string;
열쇠: string;
차례?: number;
식: string;
입력글?: string;
까닭?: string;
결과?: EqNode;
이름식?: EqNode;
값식?: EqNode;
값?: unknown;
글?: string;
표시?: number;
비목?: unknown;
}
export interface EqHandlers {
/** 변수 칩을 누름(없으면 눌리지 않음) */
onVar?: (node: EqNode) => void;
/** 하위 로직 칩을 누름 — 줄 아래 블록을 펴고 접음 */
onBlock?: (node: EqNode) => void;
/** 블록 번호 매기기용 — 이 식 안 블록 목록(부르는 쪽이 비우고 씀) */
blocks?: EqNode[];
}
const SHOW: Record<string, string> = {
"==": "=",
"!=": "≠",
"<=": "≤",
">=": "≥",
"*": "×",
"/": "÷",
"-": "−",
};
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 6 });
const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => {
const node = document.createElement("span");
node.className = className;
node.append(...kids);
return node;
};
/** 값 → 글(수는 쉼표 · 6 자리까지) */
export function eqText(value: unknown): string {
if (value === null || value === undefined || value === "") return "";
const n = typeof value === "number" ? value : Number(value);
return typeof value !== "boolean" && Number.isFinite(n) && String(value).trim() !== ""
? NUM.format(n)
: String(value);
}
/** 변수 글(기호 · 아래 · 위) — 첨자가 없으면 기호만 */
export function symbolOf(node: Pick<EqNode, "열쇠" | "기호" | "위" | "아래">): HTMLElement {
const sign = node.기호 || node.열쇠 || "";
const box = span("ui-eq__sym", sign);
if (/^[A-Za-z]{1,2}$/.test(sign)) box.classList.add("is-latin");
if (typeof node.아래 === "string" && node.아래) box.append(subOf("sub", node.아래));
if (typeof node.위 === "string" && node.위) box.append(subOf("sup", node.위));
return box;
}
const subOf = (tag: "sub" | "sup", text: string): HTMLElement => {
const node = document.createElement(tag);
node.className = "ui-eq__script";
node.textContent = text;
return node;
};
/** 한 줄 글(마우스 글 · 검사용) — 변수는 기호 · 값 꼴 */
export function plainEq(node: EqNode | undefined): string {
if (!node) return "";
switch (node.꼴) {
case "수":
case "글":
return node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? "");
case "변수": {
const sign = node.기호 || node.열쇠 || "";
const low = typeof node.아래 === "string" && node.아래 ? `_${node.아래}` : "";
const up = typeof node.위 === "string" && node.위 ? `^${node.위}` : "";
return sign + low + up;
}
case "블록":
return node.이름 || node.열쇠 || "";
case "연산":
return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`;
case "분수":
return `(${plainEq(node.위 as EqNode)}) ÷ (${plainEq(node.아래 as EqNode)})`;
case "괄호":
return `(${plainEq(node.안)})`;
case "퍼센트":
return `${plainEq(node.안)}%`;
case "음":
return `−${plainEq(node.안)}`;
case "조건":
return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`;
case "함수":
return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`;
default:
return "";
}
}
/** 변수 한 개 — 눌리면 `onVar` */
function variable(node: EqNode, h: EqHandlers): HTMLElement {
const box = span("ui-eq__var", symbolOf(node));
box.dataset.kind = node.갈래 ?? "";
box.dataset.eqVar = node.열쇠 ?? "";
const value = eqText(node.값);
box.title = [node.이름 || node.열쇠, value ? `= ${value}${node.단위 ? ` ${node.단위}` : ""}` : ""]
.filter(Boolean)
.join(" ");
if (h.onVar) {
box.classList.add("is-pick");
box.tabIndex = 0;
box.setAttribute("role", "button");
const fire = (ev: Event): void => {
ev.stopPropagation();
h.onVar?.(node);
};
box.addEventListener("click", fire);
box.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
}
return box;
}
function block(node: EqNode, h: EqHandlers): HTMLElement {
const at = (h.blocks ?? []).push(node);
const value = eqText(node.값);
const chip = span(
"ui-eq__block",
span("ui-eq__badge", String.fromCodePoint(0x245f + at)), // ① ② …
span("ui-eq__block-name", node.이름 || node.열쇠 || ""),
value ? span("ui-eq__block-val", value) : "",
);
chip.dataset.eqBlock = node.열쇠 ?? "";
chip.title = `${node.열쇠 ?? ""} ${node.이름 ?? ""}`.trim();
if (h.onBlock) {
chip.classList.add("is-pick");
chip.tabIndex = 0;
chip.setAttribute("role", "button");
const fire = (ev: Event): void => {
ev.stopPropagation();
h.onBlock?.(node);
};
chip.addEventListener("click", fire);
chip.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
}
return chip;
}
/** 수식 나무 → 인라인 요소 */
export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement {
if (!node) return span("ui-eq__expr");
const sub = (child: EqNode | string | undefined): HTMLElement =>
typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h);
switch (node.꼴) {
case "수":
return span("ui-eq__num", node.글 ?? "");
case "글":
return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""));
case "변수":
return variable(node, h);
case "블록":
return block(node, h);
case "연산": {
const sign = SHOW[node.기호 ?? ""] ?? node.기호 ?? "";
return span("ui-eq__expr", sub(node.좌), span("ui-eq__op", sign), sub(node.우));
}
case "분수": {
const frac = span(
"ui-eq__frac",
span("ui-eq__top", sub(node.위)),
span("ui-eq__bottom", sub(node.아래)),
);
if (node.사슬) frac.dataset.chain = "1";
return frac;
}
case "괄호":
return span("ui-eq__paren", span("ui-eq__inner", sub(node.안)));
case "퍼센트":
return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%"));
case "음":
return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안));
case "조건": {
const pick = node.걸림 ?? 0;
const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건));
chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`;
if (pick === 1 || pick === 2)
return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓));
return span(
"ui-eq__expr",
chip,
span("ui-eq__op", "참"),
sub(node.참),
span("ui-eq__op", "거짓"),
sub(node.거짓),
);
}
case "함수": {
const args = span("ui-eq__expr");
(node.인자 ?? []).forEach((a, i) =>
args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])),
);
return span("ui-eq__expr", span("ui-eq__fn", node.이름 ?? ""), span("ui-eq__paren", args));
}
default:
return span("ui-eq__expr", node.글 ?? "");
}
}
+244
View File
@@ -0,0 +1,244 @@
/* =============================================================================
* ui_template_equation_line.ts
* 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
* 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처)
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
* 대신하는 옛 변종 = M01 · M02 시트 줄(`createCalcSheetRow`) 의 이름 | 식 | 값 한 줄(분수 · 풀이 없음)
* ========================================================================== */
import { createButton } from "./ui_template_button";
import {
eqText,
plainEq,
renderEq,
symbolOf,
type EqHandlers,
type EqLine,
type EqNode,
} from "./ui_template_equation";
export interface EqLineOptions {
/** 블록 접힘 이름표 앞머리(블록 안 이음이 겹치지 않게) */
path: string;
/** 펼친 블록 이름표 모음 — 다시 그려도 유지 */
open: Set<string>;
/** 고른 줄 이름표(`lineId`) */
selected?: string | null;
/** 줄을 누름(없으면 안 눌림) */
onSelect?: (line: EqLine, id: string) => void;
/** 변수 누름(없으면 눌리지 않음) */
onVar?: (node: EqNode) => void;
/** 고른 줄 아래에 열 칸(식 칸 · 아이콘 줄) */
slot?: (line: EqLine, id: string) => HTMLElement | null;
/** 결과 줄 값 뒤 단위 */
resultUnit?: string;
/** 줄 번호 1 부터 보임 */
numbered?: boolean;
}
const BUNDLE: [string, string][] = [
["노무비", "노"],
["재료비", "재"],
["경비", "경"],
["계", "계"],
];
const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => {
const node = document.createElement("span");
node.className = className;
node.append(...kids);
return node;
};
const div = (className: string, ...kids: Node[]): HTMLDivElement => {
const node = document.createElement("div");
node.className = className;
node.append(...kids);
return node;
};
/** 줄 이름표 — 호표 · 덧줄은 차례 · 그 밖은 열쇠 */
export const lineId = (line: EqLine): string =>
line.갈래 === "호표" || line.갈래 === "덧줄"
? `${line.갈래}:${line.차례 ?? 0}`
: `${line.갈래}:${line.열쇠}`;
/** 식 안 변수(찾기 · 입력 · 중간 · 요소) — 열쇠마다 하나 · 블록 속은 안 들어감 */
export function varsOf(node: EqNode | undefined, out = new Map<string, EqNode>()): EqNode[] {
if (!node) return [];
if (node.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(node.갈래 ?? "")) {
if (!out.has(node.열쇠 ?? "")) out.set(node.열쇠 ?? "", node);
}
for (const k of ["좌", "우", "안", "조건", "참", "거짓"] as const) varsOf(node[k], out);
for (const k of ["위", "아래"] as const) {
const child = node[k];
if (typeof child === "object") varsOf(child, out);
}
(node.인자 ?? []).forEach((a) => varsOf(a, out));
return [...out.values()];
}
const SOURCE: Record<string, string> = { 입력: "입력", 중간: "중간 줄", 요소: "요소" };
function solveRow(node: EqNode, o: EqLineOptions): HTMLElement {
const value = eqText(node.값);
const where =
node.갈래 === "찾기"
? [node.표, ...Object.entries(node.행 ?? {}).map(([k, v]) => `${k}=${String(v)}`)]
.filter(Boolean)
.join(" · ")
: (SOURCE[node.갈래 ?? ""] ?? "");
const name = span("ui-eq__solve-name", node.이름 || node.열쇠 || "");
name.title = name.textContent ?? "";
const val = span("ui-eq__solve-val", value ? `${value}${node.단위 ? ` ${node.단위}` : ""}` : "—");
val.title = val.textContent ?? "";
const row = div(
"ui-eq__solve-row",
symbolOf(node),
span("ui-eq__op", ":"),
name,
span("ui-eq__op", "="),
val,
...(where ? [span("ui-eq__solve-src", where)] : []),
);
row.dataset.eqSolve = node.열쇠 ?? "";
if (o.onVar && node.갈래 !== "찾기" && node.갈래 !== "요소") {
row.classList.add("is-pick");
row.addEventListener("click", () => o.onVar?.(node));
}
return row;
}
function bundle(node: EqNode): HTMLElement {
const box = span("ui-eq__bundle");
const got = node.묶음;
if (got && typeof got === "object")
for (const [key, label] of BUNDLE)
if (got[key] !== undefined && got[key] !== null)
box.append(span("ui-eq__bundle-item", `${label} ${eqText(got[key])}`));
if (!box.childElementCount && node.값 !== undefined && node.값 !== null)
box.append(span("ui-eq__bundle-item", eqText(node.값)));
return box;
}
function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
const id = `${o.path}/${node.열쇠 ?? at}`;
const body = div("ui-eq__block-body");
const box = div("ui-eq__blockpanel");
box.dataset.eqBlockPanel = node.열쇠 ?? "";
box.dataset.blockId = id;
const head = createButton({
label: `${String.fromCodePoint(0x245f + at)} ${node.이름 || node.열쇠 || ""}`,
variant: "ghost",
size: 22,
iconStart: "chevronRight",
onClick: () => toggle(),
});
head.classList.add("ui-eq__block-head");
const toggle = (force?: boolean): void => {
const on = force ?? !o.open.has(id);
if (on) {
o.open.add(id);
if (!body.childElementCount && node.나무)
body.append(
createEqChain(node.나무.줄, {
path: id,
open: o.open,
resultUnit: node.결과단위 ?? node.나무.결과단위,
numbered: false,
}),
);
} else o.open.delete(id);
box.dataset.open = on ? "1" : "";
body.hidden = !on;
};
body.hidden = true;
box.append(
div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")),
body,
);
if (o.open.has(id)) toggle(true);
return box;
}
/** 줄 하나 */
export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement {
const id = lineId(line);
const root = div("ui-eq__line");
root.dataset.eqLine = id;
root.dataset.kind = line.갈래;
const blocks: EqNode[] = [];
const hand: EqHandlers = {
onVar: o.onVar,
blocks,
onBlock: (node) => {
root
.querySelector<HTMLElement>(
`[data-block-id="${CSS.escape(`${o.path}/${node.열쇠 ?? ""}`)}"] .ui-eq__block-head`,
)
?.click();
},
};
const body = div("ui-eq__body");
if (o.numbered !== false) body.append(span("ui-eq__step", String(index + 1)));
const head = line.결과;
if (head)
body.append(
span(
"ui-eq__box ui-eq__head",
head.꼴 !== "변수"
? plainEq(head)
: line.갈래 === "중간"
? renderEq(head, hand) // 누르면 변수 창
: symbolOf(head),
),
);
const resUnit =
line.갈래 === "호표" || line.갈래 === "덧줄"
? "원"
: line.갈래 === "결과"
? (o.resultUnit ?? "")
: (head?.단위 ?? "");
const result = span(
"ui-eq__box ui-eq__res",
line.글 || "—",
...(resUnit ? [span("ui-eq__unit", resUnit)] : []),
);
let vars: EqNode[] = [];
if (line.이름식) {
vars = varsOf(line.이름식);
const name = span("ui-eq__box ui-eq__name", renderEq(line.이름식, hand));
body.append(span("ui-eq__op", "="), name);
const same =
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
if (line.값식 && !same)
body.append(span("ui-eq__op", "="), span("ui-eq__box ui-eq__val", renderEq(line.값식)));
body.append(span("ui-eq__op", "="), result);
} else {
body.append(
span("ui-eq__op", "="),
span("ui-eq__box ui-eq__raw", line.식),
span("ui-eq__bad", line.까닭 ?? ""),
);
}
const solve = div("ui-eq__solve", ...vars.map((v) => solveRow(v, o)));
const main = div("ui-eq__main", body, solve);
if (o.onSelect) {
root.classList.add("is-pick");
main.addEventListener("click", () => o.onSelect?.(line, id));
}
if (o.selected === id) root.dataset.selected = "1";
root.append(main);
blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o)));
const slot = o.selected === id ? o.slot?.(line, id) : null;
if (slot) root.append(slot);
return root;
}
/** 여러 줄 위에서 아래로 이음 */
export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement {
return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o)));
}
+13
View File
@@ -29,6 +29,8 @@ import { createTabs } from "./ui_template_tabs";
import { el } from "./ui_template_elements_base";
import { createText } from "./ui_template_text";
import { createToggle } from "./ui_template_toggle";
import { createEqChain } from "./ui_template_equation_line";
import type { EqLine, EqNode } from "./ui_template_equation";
// 552-3 묶음 부품
import { createBanner } from "./ui_template_banner";
import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
@@ -208,6 +210,15 @@ const kitLampTable = (): HTMLElement =>
? `ui-data-table__lamp${i ? " is-linked" : ""}`
: `ui-data-table__lamp ui-data-table__lamp--only${i ? " is-linked" : ""}`,
}).root;
/** 796-2 견본 — Q = 3,600 × q × K × f × E ÷ c_m (분수 · 첨자 · 변수 풀이) */
const kitEquation = (): HTMLElement => {
const v = (열쇠: string, 기호: string, 값: string, 아래 = "", 갈래 = "입력", 이름 = 열쇠): EqNode => ({ 꼴: "변수", 열쇠, 이름, 기호, 아래, 값, 글: 값, 갈래 });
const n = (글: string): EqNode => ({ 꼴: "수", 글, 값: 글 });
const mul = (좌: EqNode, 우: EqNode): EqNode => ({ 꼴: "연산", 기호: "×", 좌, 우 });
const name = (a: EqNode, b: EqNode): EqNode => ({ 꼴: "분수", 위: mul(mul(mul(mul(n("3,600"), a), b), v("f", "f", "0.7")), v("E", "E", "0.75")), 아래: v("c", "c", "21", "m", "중간", "1회 싸이클시간") });
const line: EqLine = { 갈래: "결과", 열쇠: "결과", 식: "", 글: "81", 결과: { 꼴: "변수", 열쇠: "Q", 기호: "Q", 단위: "㎥/hr" }, 이름식: name(v("q", "q", "1", "", "입력", "버킷용량"), v("K", "K", "0.9", "", "찾기", "버킷계수")), 값식: { 꼴: "분수", 위: n("3,600 × 1 × 0.9 × 0.7 × 0.75"), 아래: n("21") } };
return createEqChain([line], { path: "", open: new Set(), resultUnit: "㎥/hr" });
};
const kitKeys = (...keys: string[]): DocumentFragment => {
const f = document.createDocumentFragment();
f.append(...keys.map((k) => createBadge(k, "neutral")));
@@ -381,6 +392,8 @@ export const KIT_PARTS: KitPart[] = [
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
demo: (s) => (s === "default" ? kitCalc() : null) },
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
demo: (s) => (s === "default" ? kitEquation() : null) },
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);