/* ============================================================================= * 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 { DocError, linkNames, parse, splitId, textOf, varId, type DocVar, type FormulaDoc, type Node, } from "../common_util/common_util_formula_doc"; import { binary, cut, isNum } from "../common_util/common_util_formula_doc_calc"; export type Values = Record; 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); /** 식 글 → 나무(못 풀면 null) */ export function tryParse(text: unknown): Node | null { if (text === null || text === undefined || String(text).trim() === "") return null; try { return parse(text); } catch (e) { if (e instanceof DocError) return null; throw e; } } /** 그림용 값 — 모르면 undefined */ export function evalNode(n: Node, vals: Values): unknown { const ev = (x: Node): unknown => evalNode(x, vals); try { switch (n[0]) { case "num": case "str": return n[1]; case "var": { const v = vals[n[1]]; return v === null ? undefined : v; } case "field": { const b = ev(n[1]); return b && typeof b === "object" ? (b as Values)[n[2]] : undefined; } case "neg": { const a = ev(n[1]); return isNum(a) ? -a : undefined; } case "pct": { const a = ev(n[1]); return isNum(a) ? a / 100 : undefined; } case "bin": { const a = ev(n[2]); if (a === undefined) return undefined; if (n[1] === "그리고" && !a) return a; if (n[1] === "또는" && a) return a; const b = ev(n[3]); if (b === undefined) return undefined; if (n[1] === "그리고" || n[1] === "또는") return b; const got = binary(n[1], a, b); return typeof got === "number" && !Number.isFinite(got) ? undefined : got; } case "call": { const [, name, items] = n; if (name === "만약") { const t = items.length === 3 ? ev(items[0]) : undefined; return t === undefined ? undefined : ev(t ? items[1] : items[2]); } const xs = items.map(ev); if (!xs.every(isNum)) return undefined; if (name === "최소") return Math.min(...(xs as number[])); if (name === "최대") return Math.max(...(xs as number[])); return cut(xs[0] as number, { 방법: name, 자리: Math.trunc((xs[1] as number) ?? 0) }); } } } catch { return undefined; } } /** 만약 사슬이 탄 끝 갈래가 수 하나면 그 수(만약이 아니거나 모르면 undefined) */ export function leafNum(n: Node, vals: Values): number | undefined { let at = n; let hop = 0; for (let pick = takenOf(at, vals); pick; pick = takenOf(at, vals), hop++) at = (at as ["call", string, Node[]])[2][pick]; return hop && at[0] === "num" ? at[1] : undefined; } /** 선택으로 꺼진 금액 줄 — 수량 · 단가가 만약으로 0 을 탐(663-1 · 고침 모드에서만 보임) */ export function offLine(line: { 수량?: unknown; 단가?: unknown }, vals: Values): boolean { return [line.수량, line.단가].some((t) => { const n = tryParse(t); return !!n && leafNum(n, vals) === 0; }); } /** 만약 — 탄 갈래 차례(1 참 · 2 거짓 · 0 모름) */ export function takenOf(n: Node, vals: Values): 0 | 1 | 2 { if (n[0] !== "call" || n[1] !== "만약" || n[2].length !== 3) return 0; const t = evalNode(n[2][0], vals); return t === undefined ? 0 : t ? 1 : 2; } 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)) continue; from(l.수량); from(l.단가); } if (doc.결과) visit(String(doc.결과)); return out; }