Files
Aislo/A00_Common/spreadsheet/spreadsheet_functions_math.ts
T
eomsangdonandClaude Sonnet 5 7725b39ecb feat(spreadsheet): 스프레드시트 B(함수 · 숫자 형식 · 채우기) 1단계 구현
수학 26 · 논리/글/찾기/세기 22 함수 · 엑셀 숫자 형식 코드 해석기 · 채우기 핸들(복사 · 등차 연속 · 글 끝번호 · 식 상대이동) 구현.
자기 시험 69건(resources/tester/test_spreadsheet_b.py, vite ssr 번들 → node) 전부 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
2026-09-27 20:23:45 +09:00

419 lines
12 KiB
TypeScript

/* =============================================================================
* spreadsheet_functions_math.ts (주인 B)
* 수학 · 삼각 · 끝수 함수 + 함수 표 전체가 쓰는 형 바꾸기 도우미(공용은 여기서 export).
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만 씀 · 속에 안 넣음).
* 무리수(SQRT · 삼각 · PI · 소수 거듭제곱)는 double → 유효 15 자리 십진 → 분수.
* ========================================================================== */
import {
ZERO,
add,
cmp,
div,
frac,
mul,
parseDecimal,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorCode,
ErrorValue,
EvalResult,
LazyArg,
RangeValue,
Scalar,
SheetFunction,
} from "./spreadsheet_types";
const ONE = frac(1n);
// ── 공용 도우미(math · misc 둘 다 씀) ────────────────────────────────────────
export function mkErr(code: ErrorCode, why?: string): ErrorValue {
return why === undefined ? { error: code } : { error: code, why };
}
export function isErr(v: unknown): v is ErrorValue {
return typeof v === "object" && v !== null && "error" in (v as Record<string, unknown>);
}
export function isFrac(v: unknown): v is Frac {
return typeof v === "object" && v !== null && "n" in (v as Record<string, unknown>);
}
export function isRange(v: EvalResult): v is RangeValue {
return typeof v === "object" && v !== null && (v as RangeValue).kind === "range";
}
/** 범위면 1×1 일 때만 칸 하나로 · 아니면 `#VALUE!` */
export function toScalar(v: EvalResult): Scalar | ErrorValue {
if (!isRange(v)) return v;
if (v.rows === 1 && v.cols === 1) return v.at(0, 0);
return mkErr("#VALUE!", "범위를 칸 하나 자리에 씀");
}
/** 빈 칸 0 · 참거짓 1/0 · 수 글 · 아니면 `#VALUE!` */
export function toNumber(v: Scalar): Frac | ErrorValue {
if (v === null) return ZERO;
if (isErr(v)) return v;
if (typeof v === "boolean") return v ? ONE : ZERO;
if (typeof v === "string") {
try {
return parseDecimal(v);
} catch {
return mkErr("#VALUE!", `글을 수로 못 읽음: ${v}`);
}
}
return v;
}
/** 인자 하나(범위는 1×1 만) → 수 */
export function num(v: EvalResult): Frac | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toNumber(s);
}
/** 글로 — 수는 일반 형식(간단 십진) · 참거짓 TRUE/FALSE */
export function toText(v: Scalar): string | ErrorValue {
if (v === null) return "";
if (isErr(v)) return v;
if (typeof v === "string") return v;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return generalNumberText(v);
}
export function str(v: EvalResult): string | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toText(s);
}
export function toBool(v: Scalar): boolean | ErrorValue {
if (v === null) return false;
if (isErr(v)) return v;
if (typeof v === "boolean") return v;
if (typeof v === "string") {
const up = v.trim().toUpperCase();
if (up === "TRUE") return true;
if (up === "FALSE") return false;
return mkErr("#VALUE!", `글을 참거짓으로 못 읽음: ${v}`);
}
return cmp(v, ZERO) !== 0;
}
export function bool(v: EvalResult): boolean | ErrorValue {
const s = toScalar(v);
return isErr(s) ? s : toBool(s);
}
/** double → 유효 15 자리 십진 → 분수 · NaN · 무한은 `#NUM!` */
export function fromDouble(x: number): Frac | ErrorValue {
if (!Number.isFinite(x)) return mkErr("#NUM!");
if (x === 0) return ZERO;
return parseDecimal(x.toPrecision(15));
}
export function fracToDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
/** 엑셀 「일반」 형식과 같은 십진 글(수식 `&` · TEXT 안 형식 자리가 없을 때) */
export function generalNumberText(x: Frac): string {
const scaled = toInteger(mul(x, frac(10n ** 15n)), "round");
const negative = scaled < 0n;
const digits = (negative ? -scaled : scaled).toString().padStart(16, "0");
const whole = digits.slice(0, -15).replace(/^0+(?=\d)/, "");
const fraction = digits.slice(-15).replace(/0+$/, "");
const body = fraction ? `${whole}.${fraction}` : whole;
return negative && body !== "0" ? `-${body}` : body;
}
/** 인자 목록에서 수만 모음 — 범위는 수 아닌 값 조용히 건너뜀 · 낱값은 형 바꿈(실패시 strict 면 오류)
* `propagateErrors` = 범위 · 낱값 오류를 그대로 돌려줄지(COUNT 는 false) */
export function collectNumbers(
args: LazyArg[],
propagateErrors: boolean,
strictScalar: boolean,
): Frac[] | ErrorValue {
const out: Frac[] = [];
for (const a of args) {
const v = a();
if (isRange(v)) {
for (let r = 0; r < v.rows; r++) {
for (let c = 0; c < v.cols; c++) {
const cell = v.at(r, c);
if (isErr(cell)) {
if (propagateErrors) return cell;
continue;
}
if (isFrac(cell)) out.push(cell);
}
}
} else if (isErr(v)) {
if (propagateErrors) return v;
} else if (v !== null) {
const n = toNumber(v);
if (isErr(n)) {
if (strictScalar) return n;
} else {
out.push(n);
}
}
}
return out;
}
function digitsArg(args: LazyArg[], index: number, fallback: number): number | ErrorValue {
if (index >= args.length) return fallback;
const n = num(args[index]());
return isErr(n) ? n : Math.trunc(fracToDouble(n));
}
function unaryDouble(fn: (x: number) => number): SheetFunction {
return {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return fromDouble(fn(fracToDouble(n)));
},
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
SUM: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.reduce(add, ZERO);
},
},
PRODUCT: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce(mul, ONE);
},
},
SUMPRODUCT: {
min: 1,
call: (args) => {
const arrays = args.map((a) => a());
const grids = arrays.map((v) =>
isRange(v) ? v : { rows: 1, cols: 1, at: () => (isErr(v) ? v : (v as Scalar)) },
);
const rows = grids[0].rows;
const cols = grids[0].cols;
for (const g of grids) {
if (g.rows !== rows || g.cols !== cols) return mkErr("#VALUE!", "배열 크기가 다름");
}
let sum = ZERO;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let prod = ONE;
for (const g of grids) {
const n = toNumber(g.at(r, c));
if (isErr(n)) return n;
prod = mul(prod, n);
}
sum = add(sum, prod);
}
}
return sum;
},
},
ROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "round");
},
},
ROUNDUP: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "away");
},
},
ROUNDDOWN: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
INT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(toInteger(n, "floor"));
},
},
TRUNC: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = digitsArg(args, 1, 0);
if (isErr(d)) return d;
return roundAt(n, d, "trunc");
},
},
CEILING: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "away")), sig);
},
},
FLOOR: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const sig = num(args[1]());
if (isErr(sig)) return sig;
if (cmp(sig, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(sig, ZERO) > 0) {
return mkErr("#NUM!", "수 · 기준의 부호가 다름");
}
return mul(frac(toInteger(div(n, sig), "trunc")), sig);
},
},
ABS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
return frac(n.n < 0n ? -n.n : n.n, n.d);
},
},
MOD: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const d = num(args[1]());
if (isErr(d)) return d;
if (cmp(d, ZERO) === 0) return mkErr("#DIV/0!");
const q = frac(toInteger(div(n, d), "floor"));
return sub(n, mul(d, q));
},
},
POWER: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const p = num(args[1]());
if (isErr(p)) return p;
if (p.d === 1n) {
const exp = p.n;
if (exp >= 0n) return frac(n.n ** exp, n.d ** exp);
if (n.n === 0n) return mkErr("#DIV/0!");
return frac(n.d ** -exp, n.n ** -exp);
}
return fromDouble(Math.pow(fracToDouble(n), fracToDouble(p)));
},
},
SQRT: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) < 0) return mkErr("#NUM!");
return fromDouble(Math.sqrt(fracToDouble(n)));
},
},
PI: { min: 0, max: 0, call: () => fromDouble(Math.PI) },
SIN: unaryDouble(Math.sin),
COS: unaryDouble(Math.cos),
TAN: unaryDouble(Math.tan),
ASIN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.asin(x));
},
},
ACOS: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const x = fracToDouble(n);
if (x < -1 || x > 1) return mkErr("#NUM!");
return fromDouble(Math.acos(x));
},
},
ATAN: unaryDouble(Math.atan),
RADIANS: unaryDouble((x) => (x * Math.PI) / 180),
DEGREES: unaryDouble((x) => (x * 180) / Math.PI),
MIN: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) <= 0 ? a : b));
},
},
MAX: {
min: 0,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
return values.length === 0 ? ZERO : values.reduce((a, b) => (cmp(a, b) >= 0 ? a : b));
},
},
AVERAGE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#DIV/0!");
return div(values.reduce(add, ZERO), frac(BigInt(values.length)));
},
},
};