- 서버 master_equation.subtotals — 호표 줄(하위 로직 블록 한 줄씩 · 자원 · 덧줄)의 버린 줄 비목 + 끝수(합계 − 줄 합) - 부품 ui_template_equation_sum — 줄 이음 아래 소계 표(M01 · M02 수식 탭 같이) - 시험 test_799_1_subtotals — 줄 합 + 끝수 = 합계 · 합계 = 엔진 끝수 입힌 비목 합(전후 같음) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
292 lines
9.9 KiB
TypeScript
292 lines
9.9 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|
||
|
||
/** 단가산출 소계 줄(799-1) — 갈래 블록 | 자원 | 덧줄 */
|
||
export interface EqSumRow {
|
||
갈래: string;
|
||
이름: string;
|
||
노무비: unknown;
|
||
재료비: unknown;
|
||
경비: unknown;
|
||
계: unknown;
|
||
}
|
||
|
||
/** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */
|
||
export interface EqTree {
|
||
열쇠: string;
|
||
이름: string;
|
||
결과단위: string;
|
||
줄: EqLine[];
|
||
변수표: EqVarRow[];
|
||
합계: Record<string, unknown>;
|
||
/** 799-1 — 하위 로직 블록마다 노 · 재 · 경 소계 + 끝수(합계 − 줄 합) · 결과 로직은 없음 */
|
||
소계?: { 줄: EqSumRow[]; 끝수: 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.글 ?? "");
|
||
}
|
||
}
|