Files
Aislo/A00_Common/spreadsheet/spreadsheet_functions_math.ts
T
eomsangdonandClaude Sonnet 5 d60b1ccc7e feat(spreadsheet): 분수 형식 · 함수 2차 묶음(30개) · misc 파일 쪼갬
numfmt — 분수 형식(0/0 · # ?/? · # ??/?? · 분모 고정 # ?/8) 엑셀과 같게 구현.
함수 — MROUND·ODD·EVEN·LOG·LOG10·LN·EXP(math) · SUMIFS·COUNTIFS·AVERAGEIF(S)·MAXIFS·MINIFS·
IFS·SWITCH·XLOOKUP(찾기·조건부 집계) · TEXTJOIN·TRIM·UPPER·LOWER·SUBSTITUTE·REPLACE·FIND·
SEARCH·REPT·ISNUMBER·ISTEXT·ISBLANK·ISERROR·NA·TODAY·DATE·YEAR·MONTH·DAY(misc) 더함.
misc.ts 가 700 줄을 넘어 찾기·조건부 집계를 spreadsheet_functions_lookup.ts(새 파일)로 쪼갬.
sub1 의 func_help 이름 목록에 더할 것을 spreadsheet_functions.ts 의 FUNC_HELP_BATCH2 에 둠
(그 파일은 안 고침 · 머리 주석에 읽는 길 적음). 경계값 시험 135건 통과.

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

491 lines
14 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)));
},
},
MROUND: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const m = num(args[1]());
if (isErr(m)) return m;
if (cmp(m, ZERO) === 0) return ZERO;
if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) {
return mkErr("#NUM!", "수 · 배수의 부호가 다름");
}
return mul(frac(toInteger(div(n, m), "round")), m);
},
},
ODD: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v === 0n) v = 1n;
else if (v % 2n === 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
EVEN: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
let v = toInteger(n, "away");
if (v % 2n !== 0n) v += v < 0n ? -1n : 1n;
return frac(v);
},
},
LOG: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
if (cmp(n, ZERO) <= 0) return mkErr("#NUM!");
const base = args.length > 1 ? num(args[1]()) : frac(10n);
if (isErr(base)) return base;
if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!");
return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base)));
},
},
LOG10: {
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.log10(fracToDouble(n)));
},
},
LN: {
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.log(fracToDouble(n)));
},
},
EXP: unaryDouble(Math.exp),
};