수학 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
419 lines
12 KiB
TypeScript
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)));
|
|
},
|
|
},
|
|
};
|