/* ============================================================================= * ui_template_sheet_frac.ts * 표 식의 수 — BigInt 분수. 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 의 분수 몫을 되살림. * * ⚠ 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐 — 실무 엑셀 `INT(x*100)/100` 이 * 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음. 서버(파이썬 Decimal)와 1원도 안 갈림. * ========================================================================== */ export class FormulaError extends Error {} export interface Frac { n: bigint; d: bigint; } /** 정수로 떨구는 법 — floor = 엑셀 INT · trunc = ROUNDDOWN · away = ROUNDUP · round = ROUND. */ export type IntMode = "floor" | "trunc" | "away" | "round"; const DIGITS = 30; const TEN = 10n; const abs = (x: bigint): bigint => (x < 0n ? -x : x); function gcd(a: bigint, b: bigint): bigint { a = abs(a); b = abs(b); while (b) [a, b] = [b, a % b]; return a || 1n; } export function frac(n: bigint, d = 1n): Frac { if (d === 0n) throw new FormulaError("0 으로 나눔"); if (d < 0n) [n, d] = [-n, -d]; const g = gcd(n, d); return { n: n / g, d: d / g }; } export const ZERO = frac(0n); export const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d); export const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d); export const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d); export function div(a: Frac, b: Frac): Frac { if (b.n === 0n) throw new FormulaError("0 으로 나눔"); return frac(a.n * b.d, a.d * b.n); } export function cmp(a: Frac, b: Frac): number { const left = a.n * b.d; const right = b.n * a.d; return left === right ? 0 : left < right ? -1 : 1; } /** 십진 글(`-12.5` · `3` · `1e-3` · `1,234`)을 분수로. */ export function parseDecimal(text: string): Frac { const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.replace(/,/g, "").trim()); if (!match || (match[2] === "" && (match[3] ?? "") === "")) { throw new FormulaError(`수로 읽지 못함: ${text}`); } const [, sign, whole, fraction = "", exponent = "0"] = match; let n = BigInt((whole || "0") + fraction); let d = TEN ** BigInt(fraction.length); const e = Number(exponent); if (e > 0) n *= TEN ** BigInt(e); if (e < 0) d *= TEN ** BigInt(-e); return frac(sign === "-" ? -n : n, d); } /** 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음. */ export function toFrac(value: number | string): Frac { if (typeof value === "number") { if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`); return parseDecimal(String(value)); } return parseDecimal(value); } export function toInteger(x: Frac, mode: IntMode): bigint { const q = x.n / x.d; // 0 쪽으로 자름 const r = x.n % x.d; if (r === 0n) return q; const negative = x.n < 0n; if (mode === "floor") return negative ? q - 1n : q; if (mode === "trunc") return q; if (mode === "away") return negative ? q - 1n : q + 1n; // 사사오입 — 엑셀 ROUND 와 같이 0 에서 먼 쪽 return abs(r) * 2n >= x.d ? (negative ? q - 1n : q + 1n) : q; } /** 소수 `digits` 자리에서 떨굼(음수 자리 = 십 · 백 자리). */ export function roundAt(x: Frac, digits: number, mode: IntMode): Frac { const places = Math.trunc(digits); const scale = places >= 0 ? frac(TEN ** BigInt(places)) : frac(1n, TEN ** BigInt(-places)); return div(frac(toInteger(mul(x, scale), mode)), scale); } /** 분수를 십진 글로 — 끝나는 소수는 그대로 · 안 끝나면 30자리에서 사사오입. */ export function fracToString(x: Frac): string { const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round"); const negative = scaled < 0n; const digits = abs(scaled) .toString() .padStart(DIGITS + 1, "0"); const whole = digits.slice(0, -DIGITS); const fraction = digits.slice(-DIGITS).replace(/0+$/, ""); const body = fraction ? `${whole}.${fraction}` : whole; return negative && body !== "0" ? `-${body}` : body; }