/* ============================================================================= * formula_doc_render.ts * 수식 문서 그림(PLAN 802-3 · 요구 660~664) — 식 나무 → 조판 수식(분수 · 첨자 · 괄호) · 화면 공용(M01 · M02) * 기호 식(side "sym") = 기호만(한글 이름 · 표 칸 글 · DB 키 없음) · 값 식(side "val") = 기호 자리에 값 + 단위 * 만약 = 탄 갈래만(값을 알 때) · 반올림 · 올림 · 버림 자리 감싸개 숨김(662-4) — 숨긴 글은 `data-pre` · `data-post` * 고침 글 꼴 = 공용 `serializeEq`(원자 `data-tok` · 분수 · 괄호 · 단위 뺌) — 보이는 그림이 곧 고침 자리 * evalNode = 그림용 값(탄 갈래 · 값 식) — 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) * reach = 지금 탄 갈래로 닿는 기호(정의 영역에 보일 것) * ========================================================================== */ import { eqText, symbolOf } from "@ui/ui_template_equation"; import { linkNames, splitId, textOf, varId, type DocVar, type FormulaDoc, type Node, } from "../common_util/common_util_formula_doc"; import { isNum } from "../common_util/common_util_formula_doc_calc"; import { leafNum, offLine, takenOf, tryParse, type Values, } from "../common_util/common_util_formula_doc_view"; export { evalNode, leafNum, lineMap, offLine, offZero, takenOf, tryParse, type Values, } from "../common_util/common_util_formula_doc_view"; const HIDE = new Set(["반올림", "올림", "버림"]); const SHOW: Record = { "*": "×", "/": "÷", "-": "−", "<=": "≤", ">=": "≥", "!=": "≠", }; const PREC: Record = { 또는: 1, 그리고: 2, "<": 3, "<=": 3, ">": 3, ">=": 3, "==": 3, "!=": 3, "+": 4, "-": 4, "*": 5, "/": 5, }; const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9); export interface Ctx { side: "sym" | "val"; vals: Values; vars: Map; /** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */ inline?: boolean; /** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */ cost?: string; /** 수 마디 → 차례(값 식 숫자 고침) */ lits?: Map; } const span = (cls: string, text?: string): HTMLSpanElement => { const s = document.createElement("span"); s.className = cls; if (text !== undefined) s.textContent = text; return s; }; /** 기호 글(`Q_시공^표`) → 기호 그림(첨자) */ export function symNode(id: string): HTMLElement { const [기호, 아래, 위] = splitId(id); return symbolOf({ 기호, 아래, 위, 열쇠: id }); } /** 값 글 + 단위 */ export function valueNode(value: unknown, unit = ""): HTMLElement { const box = span("fdoc__val"); box.append(span("ui-eq__num", eqText(value))); if (unit) box.append(span("ui-eq__unit", ` ${unit}`)); return box; } const paren = (inner: HTMLElement): HTMLElement => { const p = span("ui-eq__paren"); p.append(span("ui-eq__inner")); p.firstElementChild!.append(inner); return p; }; const op = (o: string): HTMLElement => span("ui-eq__op", SHOW[o] ?? o); /** 감쌈 — 그림엔 안 보이는 앞 · 뒤 글(고침 때 그대로 되돌림) */ const hidden = (pre: string, post: string, inner: HTMLElement): HTMLElement => { const w = span("fdoc__wrap"); w.dataset.pre = pre; w.dataset.post = post; w.append(inner); return w; }; function varNode(id: string, c: Ctx): HTMLElement { if (c.side === "sym") { const a = span("ui-eq__var fdoc__atom"); a.dataset.tok = id; a.dataset.var = id; a.append(symNode(id)); return a; } let v = c.vals[id]; if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost]; const a = span("ui-eq__var fdoc__atom is-val"); a.dataset.tok = id; a.dataset.var = id; if (v === null || v === undefined || typeof v === "object") { a.classList.add("is-missing"); // 값 없음 · 정의 필요 — 기호 그대로(빨강) a.append(symNode(id)); } else a.append(valueNode(v, c.vars.get(id)?.단위 ?? "")); return a; } /** 나무 → 조판 수식 */ export function renderNode(n: Node, c: Ctx): HTMLElement { const go = (x: Node): HTMLElement => renderNode(x, c); switch (n[0]) { case "num": { const s = span("ui-eq__num fdoc__lit", eqText(n[1])); const at = c.lits?.get(n); if (at !== undefined) s.dataset.lit = String(at); return s; } case "str": { const s = span("ui-eq__str fdoc__atom", n[1]); s.dataset.tok = `'${n[1]}'`; return s; } case "var": return varNode(n[1], c); case "field": { if (c.side === "val" && n[1][0] === "var") { const v = c.vals[n[1][1]]; const got = v && typeof v === "object" ? (v as Values)[n[2]] : undefined; if (isNum(got)) { const a = span("ui-eq__var fdoc__atom is-val"); a.dataset.tok = textOf(n); a.dataset.var = n[1][1]; a.append(valueNode(got)); return a; } } const a = span("ui-eq__var fdoc__atom"); a.dataset.tok = textOf(n); if (n[1][0] === "var") a.dataset.var = n[1][1]; const base = n[1][0] === "var" ? symNode(n[1][1]) : span("", textOf(n[1])); a.append(base, span("ui-eq__script", `.${n[2]}`)); return a; } case "neg": { const s = span("ui-eq__body"); s.append(op("-"), n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])); return s; } case "pct": { const s = span("ui-eq__body"); s.append(prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1]), span("ui-eq__op", "%")); return s; } case "call": { const [, name, items] = n; if (name === "만약") { const pick = takenOf(n, c.vals); if (pick) { const [t, a, b] = items.map(textOf); return pick === 1 ? hidden(`만약(${t}, `, `, ${b})`, go(items[1])) : hidden(`만약(${t}, ${a}, `, ")", go(items[2])); } } if (HIDE.has(name) && items.length >= 1 && items.length <= 2) { const post = items.length === 2 ? `, ${textOf(items[1])})` : ")"; return hidden(`${name}(`, post, go(items[0])); } const s = span("ui-eq__body"); const inner = span("ui-eq__body"); items.forEach((x, i) => { if (i) inner.append(span("ui-eq__op", ",")); inner.append(go(x)); }); s.append(span("ui-eq__fn", name), paren(inner)); return s; } case "bin": { const [, o, a, b] = n; // 선택으로 꺼진 항(만약이 0 · 1 을 탐) = 안 보임(663-1) · 고침 글은 그대로 const na = leafNum(a, c.vals); const nb = leafNum(b, c.vals); if ((o === "+" || o === "-") && nb === 0) return hidden("(", `) ${o} ${textOf(b)}`, go(a)); if ((o === "*" || o === "/") && nb === 1) return hidden("(", `) ${o} ${textOf(b)}`, go(a)); if ((o === "+" && na === 0) || (o === "*" && na === 1)) return hidden(`${textOf(a)} ${o} (`, ")", go(b)); if (o === "/" && !c.inline) { const f = span("ui-eq__frac"); const top = span("ui-eq__top"); const low = span("ui-eq__bottom"); top.append(go(a)); low.append(go(b)); f.append(top, low); return f; } const mine = PREC[o]; const s = span("ui-eq__body"); const left = go(a); const right = go(b); s.append( prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(left) : left, op(o), prec(b) <= mine ? paren(right) : right, ); return s; } } } /** 수 마디 차례(나무 걸음 차례) */ export function litsOf(n: Node): Map { const out = new Map(); const go = (x: Node): void => { if (x[0] === "num") out.set(x, out.size); if (x[0] === "bin") { go(x[2]); go(x[3]); } else if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") go(x[1]); else if (x[0] === "call") x[2].forEach(go); }; go(n); return out; } /** 차례 번째 수를 바꾼 식 글 */ export function swapLit(text: string, at: number, now: number): string { const n = tryParse(text); if (!n) return text; const lits = litsOf(n); for (const [node, i] of lits) if (i === at) node[1] = now; for (const [node] of lits) (node as [string, number, string])[2] = String(node[1]); return textOf(n); } /** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */ export function namesTaken(n: Node, vals: Values, out: string[] = []): string[] { const add = (x: string): void => { if (!out.includes(x)) out.push(x); }; switch (n[0]) { case "var": add(n[1]); break; case "bin": namesTaken(n[2], vals, out); namesTaken(n[3], vals, out); break; case "neg": case "pct": case "field": namesTaken(n[1], vals, out); break; case "call": { const pick = takenOf(n, vals); const items = pick ? [n[2][0], n[2][pick]] : n[2]; items.forEach((x) => namesTaken(x, vals, out)); break; } } return out; } const RESERVED_MONEY = new Set(["노무비", "재료비", "경비", "번지", "단계"]); /** 정의 영역에 보일 기호 — 식 줄 · 금액 줄 · 결과에서 지금 탄 갈래로 닿는 것(보이는 차례) */ export function reach(doc: FormulaDoc, vals: Values, showOff = false): string[] { const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식])); const money = new Set((doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean)); const out: string[] = []; const visit = (name: string): void => { if (!name || out.includes(name) || RESERVED_MONEY.has(name) || money.has(name)) return; out.push(name); if (lines.has(name)) { const n = tryParse(lines.get(name)); if (n) namesTaken(n, vals).forEach(visit); } const v = vars.get(name); if (v?.연결 && v.연결.갈래 !== "식") linkNames(v.연결).forEach(visit); }; const from = (text: unknown): void => { const n = tryParse(text); if (n) namesTaken(n, vals).forEach(visit); }; for (const l of doc.식 ?? []) { visit(String(l.기호 ?? "")); from(l.식); } for (const l of doc.금액 ?? []) { if (!showOff && offLine(l, vals, lines)) continue; from(l.수량); from(l.단가); } if (doc.결과) visit(String(doc.결과)); return out; }