Files
Aislo/A00_Common/formula_doc_render.ts
T
eomsangdonandClaude Opus 5.5 a003587aca fix(802-3): 선택으로 꺼진 금액 줄 숨김 · 값 식 한 번 · 옮김 군더더기 걷음(브레인 사진 지적)
- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌
  1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서)
- 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로
- 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만
- 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만
- 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:43:00 +09:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, unknown>;
const HIDE = new Set(["반올림", "올림", "버림"]);
const SHOW: Record<string, string> = {
"*": "×",
"/": "÷",
"-": "−",
"<=": "≤",
">=": "≥",
"!=": "≠",
};
const PREC: Record<string, number> = {
또는: 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<string, DocVar>;
/** ÷ 를 분수 대신 한 줄로(금액 줄 · 경쟁사 꼴) */
inline?: boolean;
/** 묶음 값의 비목(금액 묶음 줄 노 · 재 · 경 줄) */
cost?: string;
/** 수 마디 → 차례(값 식 숫자 고침) */
lits?: Map<Node, number>;
}
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<Node, number> {
const out = new Map<Node, number>();
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;
}