/* ============================================================================= * 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; 조건식?: Record; 좌?: EqNode; 우?: EqNode; 안?: EqNode; 조건?: EqNode; 참?: EqNode; 거짓?: EqNode; 걸림?: number; 이름식?: EqNode; 인자?: EqNode[]; 묶음?: Record | null; 결과단위?: string; 나무?: EqTree | null; 사슬?: boolean; } /** 단가산출 소계 묶음(799-1 · 801-3) — 갈래 블록(부른 · 더한 단가산출) | 직접(인력 · 재료 · 기계 · 덧줄 한 묶음) · M02 SK 는 상태 */ export interface EqSumRow { 갈래: string; 이름: string; 노무비: unknown; 재료비: unknown; 경비: unknown; 계: unknown; /** M02 SK — 바탕 | 덮음 | 더함 | 뺌 (M01 은 없음) */ 상태?: string; 수량?: unknown; /** 덮음 갈래(품 · 단가 · 상수 · 표값 · 식) */ 덮개?: string[]; /** 덮음 · 뺌 — 고치기 전 금액 */ 원래?: unknown; 까닭?: string; /** 직접 묶음 속 더한 · 덮은 줄(M02 SK) */ 낱줄?: { 이름: string; 상태: string; 계: unknown; 원래?: unknown }[]; /** 더한 기초의 수식(접힘 블록) */ 나무?: EqTree | null; /** 더한 기초 차례(M02 SK 더한줄 · 연결 끊기 796-2e) */ 블록?: number; /** 연결을 끊은 기초(이 SK 안 자기 사본) */ 끊음?: boolean; } /** 소계 표 덧단추 자리(796-2e) — 페이지가 줄 머리 단추 · 접힘 블록 속을 바꿔 끼움 */ export interface EqSumHooks { head?: (row: EqSumRow, at: number) => HTMLElement[]; body?: (row: EqSumRow, at: number) => HTMLElement | null; } /** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */ export interface EqTree { 열쇠: string; 이름: string; 결과단위: string; 줄: EqLine[]; 변수표: EqVarRow[]; 합계: Record; /** 799-1 · 801-3 — 묶음마다 노 · 재 · 경 소계 + 끝수(합계 − 묶음 합) · 합계 밖 줄 · 결과 로직은 없음 */ 소계?: { 줄: EqSumRow[]; 끝수: Record; 끝수까닭?: string; 밖?: { 이름: string; 밖금액: unknown }[]; }; /** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */ 답까닭?: string; /** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */ 정의?: EqDef[]; } /** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */ export interface EqDef { /** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */ 열쇠: string; 기호: string; 위: string; 아래: string; 이름: string; 갈래: string; 연결: string[]; /** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */ 고침: boolean; /** 어느 식에도 안 쓰이는 입력(지울 수 있음) */ 안씀?: boolean; } 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; /** M02 SK 고친 칸(796-2c) — 갈래(식 · 상수 · 표값 · 품 · 단가 · 변수줄) · 원래 → 지금 · 뺀줄 */ 표지?: string[]; 원래?: unknown; 지금?: unknown; 뺌?: boolean; } export interface EqHandlers { /** 변수 칩을 누름(없으면 눌리지 않음) */ onVar?: (node: EqNode) => void; /** 하위 로직 칩을 누름 — 줄 아래 블록을 펴고 접음 */ onBlock?: (node: EqNode) => void; /** 블록 번호 매기기용 — 이 식 안 블록 목록(부르는 쪽이 비우고 씀) */ blocks?: EqNode[]; } const SHOW: Record = { "==": "=", "!=": "≠", "<=": "≤", ">=": "≥", "*": "×", "/": "÷", "-": "−", }; 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): 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.글 ?? ""); } }