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
491 lines
14 KiB
TypeScript
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),
|
|
};
|