/* ============================================================================= * common_util_formula_doc_view.ts * 수식 문서 그림용 값(PLAN 802-3 · 802-4b-2 · 요구 663-1) — 화면 · DOM · fetch 없음(거울 시험이 Node 로 돌림) * tryParse · evalNode(탄 갈래 값) · takenOf · leafNum · offZero / offLine(선택으로 꺼진 줄) * decide · whyOf — 만약 = 탄 갈래만 · 만약이 낳은 0 더하기 · 1 곱하기 없앰(왼쪽 식 글 · 오른쪽 산출근거 한 벌 · 요구 695) * 셈 정본은 계산기(`common_util_formula_doc_calc.ts`) — 여기는 그림 · 숨김 판정만 * ========================================================================== */ import { DocError, parse, walk, type Node } from "./common_util_formula_doc"; import { binary, cut, isNum } from "./common_util_formula_doc_calc"; export type Values = Record; /** 식 글 → 나무(못 풀면 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 — 만약이 탄 끝 갈래가 0 · 곱 인수(× · ÷ 위) 하나가 꺼진 0 · 식 줄 기호면 그 줄 식으로 거침 * (663-1 · 802-4b-2) — 입력 · 숫자 0(구멍땜 0 개소) · 글로 적은 0 은 꺼진 것 아님 */ export function offZero( n: Node | null, vals: Values, lines: ReadonlyMap = new Map(), path: ReadonlySet = new Set(), ): boolean { if (!n) return false; 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]; if (hop && at[0] === "num") return at[1] === 0; const off = (x: Node): boolean => offZero(x, vals, lines, path); switch (at[0]) { case "var": return ( lines.has(at[1]) && !path.has(at[1]) && offZero(tryParse(lines.get(at[1])), vals, lines, new Set([...path, at[1]])) ); case "neg": case "pct": return off(at[1]); case "bin": if (at[1] === "*") return off(at[2]) || off(at[3]); if (at[1] === "/") return off(at[2]); if (at[1] === "+" || at[1] === "-") return off(at[2]) && off(at[3]); return false; default: return false; } } /** 식 줄 표 {기호: 식} — `offLine` 이 계산 변수를 거칠 때 */ export const lineMap = (doc: { 식?: { 기호?: unknown; 식?: unknown }[] }): Map => new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식])); /** 선택으로 꺼진 금액 줄 — 수량 · 단가가 선택으로 꺼진 0(`offZero` · 고침 모드에서만 보임) */ export function offLine( line: { 수량?: unknown; 단가?: unknown }, vals: Values, lines: ReadonlyMap = new Map(), ): boolean { return [line.수량, line.단가].some((t) => offZero(tryParse(t), vals, lines)); } /** 만약 — 탄 갈래 차례(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; } /** 금액 줄 i 가 부르는 비목 합(노무비 · 재료비 · 경비 = 앞 줄 누계 · 합밖 · 제외 단계 줄 뺌) * — 계산기 `money` 의 running 과 같은 값(셈 답 `줄` 의 비목에서 다시 모음 · 802-4b-3) */ export function moneyScope(rows: unknown, i: number): Values { const acc: Record = { 노무비: 0, 재료비: 0, 경비: 0 }; ((rows ?? []) as { 비목?: Record; 합밖?: unknown }[]) .slice(0, i) .forEach((r) => { if (r.합밖) return; for (const k of Object.keys(acc)) if (isNum(r.비목?.[k])) acc[k] += r.비목![k] as number; }); return acc; } /** 결정된 식 — 지금 값으로 탄 갈래만 남긴 나무(모르는 조건의 만약은 그대로) · 셈 값은 원 식과 같음 * 아래에서 위로: 만약이 낳은 수(made)는 접음(-0 · 0 ÷ 100 · 1 - 0 → 1) · 0 더하기 · 빼기 · 1 곱하기 · 나누기 · 0 곱하기 없앰 · * 만약이 낳은 음수 부호는 위로 올려 「a - -x」 → 「a + x」 · 만약 없는 식 조각은 손대지 않음 */ export function decide(n: Node, vals: Values): Node { const made = new WeakSet(); // 만약이 낳은 수 const sign = new WeakSet(); // 만약에서 온 「-x」 const num = (v: number): Node => { const x: Node = ["num", v, String(Number(v.toPrecision(12)))]; made.add(x); return x; }; const isMade = (x: Node): x is ["num", number, string] => x[0] === "num" && made.has(x); const neg = (x: Node): Node => { if (x[0] === "num") return num(-x[1]); if (x[0] === "neg") return x[1]; const y: Node = ["neg", x]; sign.add(y); return y; }; const go = (x: Node): Node => { if (!hasIf(x)) return x; switch (x[0]) { case "call": { const pick = takenOf(x, vals); if (x[1] === "만약" && pick) { const r = go(x[2][pick]); return r[0] === "num" ? num(r[1]) : r[0] === "neg" ? neg(r[1]) : r; } return ["call", x[1], x[2].map(go)]; } case "neg": { const c = go(x[1]); const fromIf = x[1][0] === "call" && x[1][1] === "만약"; // 「-만약(…)」 = 만약에서 온 부호 return fromIf || isMade(c) || sign.has(c) || c[0] === "neg" ? neg(c) : ["neg", c]; } case "pct": return ["pct", go(x[1])]; case "field": return ["field", go(x[1]), x[2]]; case "bin": { const o = x[1]; const a = go(x[2]); const b = go(x[3]); if (!"+-*/".includes(o) || o.length !== 1) return ["bin", o, a, b]; const ma = isMade(a); const mb = isMade(b); if (a[0] === "num" && b[0] === "num" && (ma || mb)) { const v = binary(o, a[1], b[1]); if (typeof v === "number" && Number.isFinite(v)) return num(v); } if ((o === "+" || o === "-") && mb && b[1] === 0) return a; if (o === "+" && ma && a[1] === 0) return b; if (o === "-" && ma && a[1] === 0) return neg(b); if ((o === "*" || o === "/") && mb && b[1] === 1) return a; if (o === "*" && ma && a[1] === 1) return b; if ( (o === "*" && ((ma && a[1] === 0) || (mb && b[1] === 0))) || (o === "/" && ma && a[1] === 0) ) return num(0); if ((o === "*" || o === "/") && sign.has(a)) return neg(["bin", o, (a as ["neg", Node])[1], b]); if (o === "-" && sign.has(b)) return ["bin", "+", a, (b as ["neg", Node])[1]]; if (o === "+" && sign.has(b)) return ["bin", "-", a, (b as ["neg", Node])[1]]; return ["bin", o, a, b]; } default: return x; } }; return go(n); } export const hasIf = (n: Node): boolean => [...walk(n)].some((x) => x[0] === "call" && x[1] === "만약"); /** 결정 근거 — 탄 길 위 만약마다 {조건, 참?, 탄 갈래(결정된 꼴)} · 안 탄 갈래 속 만약은 안 셈 */ export function whyOf(n: Node, vals: Values): { cond: Node; yes: boolean; to: Node }[] { const out: { cond: Node; yes: boolean; to: Node }[] = []; const visit = (x: Node): void => { const pick = takenOf(x, vals); if (pick) { const items = (x as ["call", string, Node[]])[2]; out.push({ cond: items[0], yes: pick === 1, to: decide(items[pick], vals) }); return visit(items[pick]); } if (x[0] === "neg" || x[0] === "pct" || x[0] === "field") visit(x[1]); else if (x[0] === "call") x[2].forEach(visit); else if (x[0] === "bin") (visit(x[2]), visit(x[3])); }; visit(n); return out; }