- 공식 줄 만약(IF) 식 = 지금 옵션 · 중간값으로 탄 갈래만(decide) · 만약이 낳은 0 · 1 접음 · 「a - -x」 → 「a + x」 · 셈은 그대로(판정 = 계산기 값) - 바로 아래 근거 한 줄 — 옵션 비교 「지붕슬래브 「아님」 → 0」 · 수치 비교 「H = 9 > 7 → …」 · 규칙 보기 단추 없음(M01 도) - 오른쪽 산출근거 = 같은 decide 한 벌(tex 의 만약 · 0 · 1 규칙을 걷고 decide 부름) - 결정된 식을 고치면 그 글로 굳음(만약 사라짐 · 옵션 바꿔도 안 따라감) · 저장본에 만약이 있던 줄 = 「옵션 규칙이 풀렸음 · 되돌리려면 [되돌림]」 표지 · M01 · M02 같은 부품 - 1,518 전수 — 만약 든 388 문서 663 줄 · 결정된 식 값 = 원 식 = 셈 답 · 남은 만약 0 · 근거 1,062 - test_804_12 둘 · helper 「결정」 할일 · test_804_7 머리 단추 기대를 804-11 deleteButton 으로 고침(낡은 기대) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
9.8 KiB
TypeScript
248 lines
9.8 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, unknown>;
|
|
|
|
/** 식 글 → 나무(못 풀면 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<string, unknown> = new Map(),
|
|
path: ReadonlySet<string> = 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<string, unknown> =>
|
|
new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
|
|
|
|
/** 선택으로 꺼진 금액 줄 — 수량 · 단가가 선택으로 꺼진 0(`offZero` · 고침 모드에서만 보임) */
|
|
export function offLine(
|
|
line: { 수량?: unknown; 단가?: unknown },
|
|
vals: Values,
|
|
lines: ReadonlyMap<string, unknown> = 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<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
|
|
((rows ?? []) as { 비목?: Record<string, unknown>; 합밖?: 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<Node>(); // 만약이 낳은 수
|
|
const sign = new WeakSet<Node>(); // 만약에서 온 「-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;
|
|
}
|