- 단위 규칙 한 벌(common_util_line_stack_unit) — 왼쪽 값 칸 · 식 줄 · 옵션 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀 · 메타 단위(구분 따름 · 수(…) · 원문 단위 없음) 안 그림 · %(설명) = % · 10^-7 = 10⁻⁷ · 기계 칸 연결 = 기계 표 칸 단위 · 코드 값 쉼표 없음 - 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」 · 소계 · 합계 · 계 = 원 · 단가 · 수량 한 덩이(줄 바뀜에 단위 안 떨어짐) - 기계경비식 겹분수 → 중간 줄(상여 · 휴지 · 손료계수 · common_util_line_stack_frac) · 한 줄 분수 한 층 - 기계 제어 구역 단위 = 칸 밖 바로 뒤 · 기계 보기 오른쪽 = 박기 전 기계 칸 단위 - 휴대폰 폭(560 px 밑) 산출근거 금액 칸 넷 걷음(가로 넘침 0) · 분수 줄 위아래 여유 - 전수 1,520 표시 탓 43,218 → 0 · 기계 겹분수 2 층 넘는 줄 1,040 → 0 · 금액 · 식 · 문서 자료 불변(그리기만) - 시험 test_845_2_unit_show(차린 줄 글 전수 지킴) · shownUnit 걷음(규칙 한 벌로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.5 KiB
TypeScript
135 lines
5.5 KiB
TypeScript
/* =============================================================================
|
|
* common_util_line_stack_frac.ts
|
|
* 산출근거 분수 층(PLAN 845-2 · 요구 880 「분모 · 분자가 쌓여 층층이 → 행으로 나눔」)
|
|
* 층 = 조판(`line_stack_tex`)과 같은 셈 — ÷ 하나 = 분수 한 층 · 감싸개(반올림 · 올림 · 버림)는 안 셈
|
|
* 기계경비식(`splitFrac`) = 한 줄에 분수 한 층까지:
|
|
* ① 곱 · 나눗셈 사슬에 ÷ 가 둘 넘으면 「a ÷ b」 를 짝으로 나란히(겹 대신 · 값 같음)
|
|
* ② 「…분자 ÷ …분모」 짝(그림 이름 머리가 줄 이름과 다름) = 중간 줄 「상여 = 16 ÷ 12」
|
|
* ③ 분수 안 덧셈 묶음이 또 분수를 품으면 = 중간 줄(그림 사전 `partName` — 「손료계수」)
|
|
* ④ 그래도 남는 안쪽 분수 = 조판이 ÷ 한 줄로(`TexCtx.frac`)
|
|
* 그리기만 — 문서 식 · 값 · 금액 불변 · 중간 줄 값 = 같은 식 조각을 같은 셈(`evalNode`) · 화면 없음(시험이 Node 로 돌림)
|
|
* ========================================================================== */
|
|
|
|
import type { Node } from "./common_util_formula_doc";
|
|
import { evalNode, type Values } from "./common_util_formula_doc_view";
|
|
|
|
const HIDE = new Set(["반올림", "올림", "버림"]);
|
|
const hidden = (n: Node): n is ["call", string, Node[]] =>
|
|
n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2;
|
|
|
|
/** 조판 분수 층 수 */
|
|
export function fracDepth(n: Node): number {
|
|
switch (n[0]) {
|
|
case "neg":
|
|
case "pct":
|
|
return fracDepth(n[1]);
|
|
case "call": {
|
|
const items = n[2];
|
|
return hidden(n) ? fracDepth(items[0]) : Math.max(0, ...items.map(fracDepth));
|
|
}
|
|
case "bin": {
|
|
const d = Math.max(fracDepth(n[2]), fracDepth(n[3]));
|
|
return n[1] === "/" ? d + 1 : d;
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/** 왼쪽으로 쌓인 곱 · 나눗셈 사슬 → [연산, 항] */
|
|
function chain(n: Node): ["*" | "/", Node][] {
|
|
if (n[0] === "bin" && (n[1] === "*" || n[1] === "/"))
|
|
return [...chain(n[2]), [n[1] as "*" | "/", n[3]]];
|
|
return [["*", n]];
|
|
}
|
|
const product = (xs: Node[]): Node => xs.reduce((a, b) => ["bin", "*", a, b]);
|
|
const isFrac = (n: Node): n is ["bin", string, Node, Node] => n[0] === "bin" && n[1] === "/";
|
|
|
|
/** ① ÷ 둘 넘는 사슬 = 「a ÷ b」 짝을 나란히 — 감싸개 · 괄호 안까지 */
|
|
export function regroup(n: Node): Node {
|
|
switch (n[0]) {
|
|
case "neg":
|
|
return ["neg", regroup(n[1])];
|
|
case "pct":
|
|
return ["pct", regroup(n[1])];
|
|
case "call":
|
|
return ["call", n[1], n[2].map(regroup)];
|
|
case "bin": {
|
|
const items = chain(n);
|
|
if (items.length < 2 || items.filter(([o]) => o === "/").length < 2)
|
|
return ["bin", n[1], regroup(n[2]), regroup(n[3])];
|
|
const out: Node[] = [];
|
|
for (const [o, raw] of items) {
|
|
const x = regroup(raw);
|
|
const last = out[out.length - 1];
|
|
if (o === "*" || !last) out.push(x);
|
|
else if (isFrac(last))
|
|
out[out.length - 1] = ["bin", "/", last[2], ["bin", "*", last[3], x]];
|
|
else out[out.length - 1] = ["bin", "/", last, x];
|
|
}
|
|
return product(out);
|
|
}
|
|
default:
|
|
return n;
|
|
}
|
|
}
|
|
|
|
export interface FracRow {
|
|
/** 중간 줄 이름(그림 전용 — 문서 기호 아님) */
|
|
id: string;
|
|
node: Node;
|
|
}
|
|
|
|
const numOk = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
|
|
/** 기계경비식 한 줄 → 중간 줄 + 줄인 식 · 중간 값(`vals` 에 얹을 것) */
|
|
export function splitFrac(
|
|
id: string,
|
|
n: Node,
|
|
alias: Record<string, string>,
|
|
taken: Set<string>,
|
|
vals: Values,
|
|
part?: string,
|
|
): { rows: FracRow[]; node: Node; vals: Values } {
|
|
const rows: FracRow[] = [];
|
|
const got: Values = {};
|
|
const name = (x: string): string => alias[x] ?? x;
|
|
const own = name(id);
|
|
const take = (nm: string, sub: Node): Node | null => {
|
|
if (!nm || taken.has(nm) || nm in got) return null;
|
|
const v = evalNode(sub, { ...vals, ...got });
|
|
if (!numOk(v)) return null;
|
|
got[nm] = v;
|
|
rows.push({ id: nm, node: sub });
|
|
return ["var", nm];
|
|
};
|
|
// 감싸개(버림 등)는 그대로 두고 안쪽만
|
|
const inner = (x: Node, fn: (y: Node) => Node): Node =>
|
|
hidden(x) ? ["call", x[1], [inner(x[2][0], fn), ...x[2].slice(1)]] : fn(x);
|
|
let node = inner(regroup(n), (x) => {
|
|
// ② 분자 · 분모 짝(곱 사슬의 분수 항)
|
|
const factors = (y: Node): Node[] =>
|
|
y[0] === "bin" && y[1] === "*" ? [...factors(y[2]), y[3]] : [y];
|
|
const parts = factors(x).map((f) => {
|
|
if (!isFrac(f) || f[2][0] !== "var" || f[3][0] !== "var") return f;
|
|
const a = /^(.+)분자$/.exec(name(f[2][1]));
|
|
const b = /^(.+)분모$/.exec(name(f[3][1]));
|
|
return a && b && a[1] === b[1] && a[1] !== own ? (take(a[1], f) ?? f) : f;
|
|
});
|
|
return product(parts);
|
|
});
|
|
// ③ 분수 안 덧셈 묶음이 분수를 품음 = 중간 줄(사전 이름 있을 때만)
|
|
if (part && fracDepth(node) > 1) {
|
|
const dig = (x: Node, inFrac: boolean): Node => {
|
|
if (x[0] === "bin" && (x[1] === "+" || x[1] === "-") && inFrac && fracDepth(x) >= 1)
|
|
return take(part, x) ?? x;
|
|
if (x[0] === "bin") return ["bin", x[1], dig(x[2], inFrac || x[1] === "/"), dig(x[3], inFrac || x[1] === "/")]; // prettier-ignore
|
|
if (x[0] === "call") return ["call", x[1], x[2].map((y) => dig(y, inFrac))];
|
|
if (x[0] === "neg" || x[0] === "pct") return [x[0], dig(x[1], inFrac)] as Node;
|
|
return x;
|
|
};
|
|
node = dig(node, false);
|
|
}
|
|
return { rows, node, vals: got };
|
|
}
|