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
537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬)
|
|
* 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서
|
|
* VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF ·
|
|
* SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴.
|
|
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
ZERO,
|
|
add,
|
|
cmp,
|
|
div,
|
|
frac,
|
|
parseDecimal,
|
|
type Frac,
|
|
} from "@ui/sheet/ui_template_sheet_frac";
|
|
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
|
|
import {
|
|
bool,
|
|
collectNumbers,
|
|
fracToDouble,
|
|
isErr,
|
|
isFrac,
|
|
isRange,
|
|
mkErr,
|
|
num,
|
|
toNumber,
|
|
toScalar,
|
|
} from "./spreadsheet_functions_math";
|
|
|
|
const ONE = frac(1n);
|
|
|
|
// ── 값 견줌(찾기 · 세기 함수 공용) ───────────────────────────────────────────
|
|
|
|
function typeRank(v: Scalar): number {
|
|
if (v === null) return -1;
|
|
if (isFrac(v)) return 0;
|
|
if (typeof v === "string") return 1;
|
|
if (typeof v === "boolean") return 2;
|
|
return 3;
|
|
}
|
|
|
|
function compareScalars(a: Scalar, b: Scalar): number {
|
|
if (isFrac(a) && isFrac(b)) return cmp(a, b);
|
|
if (typeof a === "string" && typeof b === "string") {
|
|
const la = a.toUpperCase();
|
|
const lb = b.toUpperCase();
|
|
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
}
|
|
if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1;
|
|
return 0;
|
|
}
|
|
|
|
function equalScalars(a: Scalar, b: Scalar): boolean {
|
|
return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0;
|
|
}
|
|
|
|
function wrapScalar(v: Scalar): RangeValue {
|
|
return { kind: "range", rows: 1, cols: 1, at: () => v };
|
|
}
|
|
|
|
// ── 조건 글(`>10` · `<=5` · `사과*`) ─────────────────────────────────────────
|
|
|
|
function wildcardToRegex(text: string): RegExp {
|
|
const escaped = text
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(`^${escaped}$`, "i");
|
|
}
|
|
|
|
function parseCriteria(crit: Scalar): (cell: Scalar) => boolean {
|
|
if (crit === null) return (cell) => cell === null;
|
|
if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit;
|
|
if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0;
|
|
if (isErr(crit)) return () => false;
|
|
const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit);
|
|
const op = m?.[1] ?? "";
|
|
const rest = m?.[2] ?? crit;
|
|
let numRest: Frac | null = null;
|
|
try {
|
|
numRest = rest.trim() === "" ? null : parseDecimal(rest);
|
|
} catch {
|
|
numRest = null;
|
|
}
|
|
if (numRest !== null) {
|
|
const nr = numRest;
|
|
return (cell) => {
|
|
if (!isFrac(cell)) return false;
|
|
const c = cmp(cell, nr);
|
|
if (op === "<=") return c <= 0;
|
|
if (op === ">=") return c >= 0;
|
|
if (op === "<>") return c !== 0;
|
|
if (op === "<") return c < 0;
|
|
if (op === ">") return c > 0;
|
|
return c === 0;
|
|
};
|
|
}
|
|
const pat = wildcardToRegex(rest);
|
|
if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell));
|
|
return (cell) => typeof cell === "string" && pat.test(cell);
|
|
}
|
|
|
|
/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용.
|
|
* `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */
|
|
function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue {
|
|
const mask = new Array<boolean>(shape.rows * shape.cols).fill(true);
|
|
for (let i = from; i + 1 < args.length; i += 2) {
|
|
const rangeArg = args[i]();
|
|
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
|
if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) {
|
|
return mkErr("#VALUE!", "조건 범위 크기가 다름");
|
|
}
|
|
const crit = toScalar(args[i + 1]());
|
|
if (isErr(crit)) return crit;
|
|
const pred = parseCriteria(crit);
|
|
let k = 0;
|
|
for (let r = 0; r < shape.rows; r++) {
|
|
for (let c = 0; c < shape.cols; c++) {
|
|
if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false;
|
|
k++;
|
|
}
|
|
}
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
|
|
|
export const LOOKUP_FUNCTIONS: Record<string, SheetFunction> = {
|
|
VLOOKUP: {
|
|
min: 3,
|
|
max: 4,
|
|
call: (args) => {
|
|
const lookup = toScalar(args[0]());
|
|
if (isErr(lookup)) return lookup;
|
|
const table = args[1]();
|
|
if (!isRange(table)) return mkErr("#VALUE!");
|
|
const colN = num(args[2]());
|
|
if (isErr(colN)) return colN;
|
|
const col = Math.trunc(fracToDouble(colN)) - 1;
|
|
if (col < 0 || col >= table.cols) return mkErr("#REF!");
|
|
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
|
if (isErr(approxRaw)) return approxRaw;
|
|
if (approxRaw) {
|
|
let best = -1;
|
|
for (let r = 0; r < table.rows; r++) {
|
|
const key = table.at(r, 0);
|
|
if (typeRank(key) !== typeRank(lookup)) {
|
|
if (best >= 0) break;
|
|
continue;
|
|
}
|
|
if (compareScalars(key, lookup) <= 0) best = r;
|
|
else break;
|
|
}
|
|
if (best < 0) return mkErr("#N/A");
|
|
return table.at(best, col);
|
|
}
|
|
for (let r = 0; r < table.rows; r++) {
|
|
if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col);
|
|
}
|
|
return mkErr("#N/A");
|
|
},
|
|
},
|
|
HLOOKUP: {
|
|
min: 3,
|
|
max: 4,
|
|
call: (args) => {
|
|
const lookup = toScalar(args[0]());
|
|
if (isErr(lookup)) return lookup;
|
|
const table = args[1]();
|
|
if (!isRange(table)) return mkErr("#VALUE!");
|
|
const rowN = num(args[2]());
|
|
if (isErr(rowN)) return rowN;
|
|
const row = Math.trunc(fracToDouble(rowN)) - 1;
|
|
if (row < 0 || row >= table.rows) return mkErr("#REF!");
|
|
const approxRaw = args.length > 3 ? bool(args[3]()) : true;
|
|
if (isErr(approxRaw)) return approxRaw;
|
|
if (approxRaw) {
|
|
let best = -1;
|
|
for (let c = 0; c < table.cols; c++) {
|
|
const key = table.at(0, c);
|
|
if (typeRank(key) !== typeRank(lookup)) {
|
|
if (best >= 0) break;
|
|
continue;
|
|
}
|
|
if (compareScalars(key, lookup) <= 0) best = c;
|
|
else break;
|
|
}
|
|
if (best < 0) return mkErr("#N/A");
|
|
return table.at(row, best);
|
|
}
|
|
for (let c = 0; c < table.cols; c++) {
|
|
if (equalScalars(table.at(0, c), lookup)) return table.at(row, c);
|
|
}
|
|
return mkErr("#N/A");
|
|
},
|
|
},
|
|
INDEX: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const rangeArg = args[0]();
|
|
if (isErr(rangeArg)) return rangeArg;
|
|
const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg);
|
|
const rowN = num(args[1]());
|
|
if (isErr(rowN)) return rowN;
|
|
const row = Math.trunc(fracToDouble(rowN));
|
|
const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n);
|
|
if (isErr(colN)) return colN;
|
|
const col = Math.trunc(fracToDouble(colN));
|
|
if (row === 0 && col === 0) return mkErr("#VALUE!");
|
|
if (row === 0) {
|
|
if (col < 1 || col > range.cols) return mkErr("#REF!");
|
|
const c = col - 1;
|
|
return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) };
|
|
}
|
|
if (col === 0) {
|
|
if (row < 1 || row > range.rows) return mkErr("#REF!");
|
|
const r = row - 1;
|
|
return {
|
|
kind: "range",
|
|
rows: 1,
|
|
cols: range.cols,
|
|
at: (_r: number, c: number) => range.at(r, c),
|
|
};
|
|
}
|
|
if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!");
|
|
return range.at(row - 1, col - 1);
|
|
},
|
|
},
|
|
MATCH: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const lookup = toScalar(args[0]());
|
|
if (isErr(lookup)) return lookup;
|
|
const rangeArg = args[1]();
|
|
if (!isRange(rangeArg)) return mkErr("#VALUE!");
|
|
if (rangeArg.rows !== 1 && rangeArg.cols !== 1) {
|
|
return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만");
|
|
}
|
|
const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows;
|
|
const at = (i: number): Scalar =>
|
|
rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0);
|
|
const modeN = args.length > 2 ? num(args[2]()) : ONE;
|
|
if (isErr(modeN)) return modeN;
|
|
const mode = Math.trunc(fracToDouble(modeN));
|
|
if (mode === 0) {
|
|
for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1));
|
|
return mkErr("#N/A");
|
|
}
|
|
let best = -1;
|
|
for (let i = 0; i < n; i++) {
|
|
const key = at(i);
|
|
if (typeRank(key) !== typeRank(lookup)) {
|
|
if (best >= 0) break;
|
|
continue;
|
|
}
|
|
const c = compareScalars(key, lookup);
|
|
if (mode > 0 ? c <= 0 : c >= 0) best = i;
|
|
else break;
|
|
}
|
|
if (best < 0) return mkErr("#N/A");
|
|
return frac(BigInt(best + 1));
|
|
},
|
|
},
|
|
CHOOSE: {
|
|
min: 2,
|
|
call: (args) => {
|
|
const idxN = num(args[0]());
|
|
if (isErr(idxN)) return idxN;
|
|
const idx = Math.trunc(fracToDouble(idxN));
|
|
if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!");
|
|
return args[idx]();
|
|
},
|
|
},
|
|
COUNT: {
|
|
min: 0,
|
|
call: (args) => {
|
|
const values = collectNumbers(args, false, false);
|
|
return isErr(values) ? values : frac(BigInt(values.length));
|
|
},
|
|
},
|
|
COUNTA: {
|
|
min: 0,
|
|
call: (args) => {
|
|
let count = 0;
|
|
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++) if (v.at(r, c) !== null) count++;
|
|
}
|
|
} else if (v !== null) {
|
|
count++;
|
|
}
|
|
}
|
|
return frac(BigInt(count));
|
|
},
|
|
},
|
|
COUNTIF: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const crit = toScalar(args[1]());
|
|
if (isErr(crit)) return crit;
|
|
const pred = parseCriteria(crit);
|
|
let count = 0;
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++;
|
|
}
|
|
return frac(BigInt(count));
|
|
},
|
|
},
|
|
SUMIF: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const crit = toScalar(args[1]());
|
|
if (isErr(crit)) return crit;
|
|
const pred = parseCriteria(crit);
|
|
const sumRange = args.length > 2 ? args[2]() : range;
|
|
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
|
if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) {
|
|
return mkErr("#VALUE!", "합 범위 크기가 다름");
|
|
}
|
|
let total = ZERO;
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) {
|
|
if (!pred(range.at(r, c))) continue;
|
|
const n = toNumber(sumRange.at(r, c));
|
|
if (isErr(n)) return n;
|
|
total = add(total, n);
|
|
}
|
|
}
|
|
return total;
|
|
},
|
|
},
|
|
SUMIFS: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const sumRange = args[0]();
|
|
if (!isRange(sumRange)) return mkErr("#VALUE!");
|
|
const mask = criteriaMask(args, 1, sumRange);
|
|
if (isErr(mask)) return mask;
|
|
let total = ZERO;
|
|
let k = 0;
|
|
for (let r = 0; r < sumRange.rows; r++) {
|
|
for (let c = 0; c < sumRange.cols; c++) {
|
|
if (mask[k]) {
|
|
const n = toNumber(sumRange.at(r, c));
|
|
if (isErr(n)) return n;
|
|
total = add(total, n);
|
|
}
|
|
k++;
|
|
}
|
|
}
|
|
return total;
|
|
},
|
|
},
|
|
COUNTIFS: {
|
|
min: 2,
|
|
call: (args) => {
|
|
const first = args[0]();
|
|
if (!isRange(first)) return mkErr("#VALUE!");
|
|
const mask = criteriaMask(args, 0, first);
|
|
if (isErr(mask)) return mask;
|
|
return frac(BigInt(mask.filter(Boolean).length));
|
|
},
|
|
},
|
|
AVERAGEIF: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const range = args[0]();
|
|
if (!isRange(range)) return mkErr("#VALUE!");
|
|
const crit = toScalar(args[1]());
|
|
if (isErr(crit)) return crit;
|
|
const pred = parseCriteria(crit);
|
|
const avgRange = args.length > 2 ? args[2]() : range;
|
|
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
|
if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) {
|
|
return mkErr("#VALUE!", "평균 범위 크기가 다름");
|
|
}
|
|
let total = ZERO;
|
|
let count = 0;
|
|
for (let r = 0; r < range.rows; r++) {
|
|
for (let c = 0; c < range.cols; c++) {
|
|
if (!pred(range.at(r, c))) continue;
|
|
const n = toNumber(avgRange.at(r, c));
|
|
if (isErr(n)) return n;
|
|
total = add(total, n);
|
|
count++;
|
|
}
|
|
}
|
|
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
|
},
|
|
},
|
|
AVERAGEIFS: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const avgRange = args[0]();
|
|
if (!isRange(avgRange)) return mkErr("#VALUE!");
|
|
const mask = criteriaMask(args, 1, avgRange);
|
|
if (isErr(mask)) return mask;
|
|
let total = ZERO;
|
|
let count = 0;
|
|
let k = 0;
|
|
for (let r = 0; r < avgRange.rows; r++) {
|
|
for (let c = 0; c < avgRange.cols; c++) {
|
|
if (mask[k]) {
|
|
const n = toNumber(avgRange.at(r, c));
|
|
if (isErr(n)) return n;
|
|
total = add(total, n);
|
|
count++;
|
|
}
|
|
k++;
|
|
}
|
|
}
|
|
return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count)));
|
|
},
|
|
},
|
|
MAXIFS: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const targetRange = args[0]();
|
|
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
|
const mask = criteriaMask(args, 1, targetRange);
|
|
if (isErr(mask)) return mask;
|
|
let best: Frac | null = null;
|
|
let k = 0;
|
|
for (let r = 0; r < targetRange.rows; r++) {
|
|
for (let c = 0; c < targetRange.cols; c++) {
|
|
if (mask[k]) {
|
|
const cell = targetRange.at(r, c);
|
|
if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell;
|
|
}
|
|
k++;
|
|
}
|
|
}
|
|
return best ?? ZERO;
|
|
},
|
|
},
|
|
MINIFS: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const targetRange = args[0]();
|
|
if (!isRange(targetRange)) return mkErr("#VALUE!");
|
|
const mask = criteriaMask(args, 1, targetRange);
|
|
if (isErr(mask)) return mask;
|
|
let best: Frac | null = null;
|
|
let k = 0;
|
|
for (let r = 0; r < targetRange.rows; r++) {
|
|
for (let c = 0; c < targetRange.cols; c++) {
|
|
if (mask[k]) {
|
|
const cell = targetRange.at(r, c);
|
|
if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell;
|
|
}
|
|
k++;
|
|
}
|
|
}
|
|
return best ?? ZERO;
|
|
},
|
|
},
|
|
IFS: {
|
|
min: 2,
|
|
call: (args) => {
|
|
for (let i = 0; i + 1 < args.length; i += 2) {
|
|
const c = bool(args[i]());
|
|
if (isErr(c)) return c;
|
|
if (c) return args[i + 1]();
|
|
}
|
|
return mkErr("#N/A");
|
|
},
|
|
},
|
|
SWITCH: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const target = toScalar(args[0]());
|
|
if (isErr(target)) return target;
|
|
let i = 1;
|
|
for (; i + 1 < args.length; i += 2) {
|
|
const cand = toScalar(args[i]());
|
|
if (isErr(cand)) return cand;
|
|
if (equalScalars(target, cand)) return args[i + 1]();
|
|
}
|
|
return i < args.length ? args[i]() : mkErr("#N/A");
|
|
},
|
|
},
|
|
XLOOKUP: {
|
|
min: 3,
|
|
max: 6,
|
|
call: (args) => {
|
|
const lookup = toScalar(args[0]());
|
|
if (isErr(lookup)) return lookup;
|
|
const lookupRange = args[1]();
|
|
const returnRange = args[2]();
|
|
if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!");
|
|
const n = lookupRange.rows * lookupRange.cols;
|
|
if (n !== returnRange.rows * returnRange.cols) {
|
|
return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름");
|
|
}
|
|
const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0));
|
|
const modeN = args.length > 4 ? num(args[4]()) : ZERO;
|
|
if (isErr(modeN)) return modeN;
|
|
const mode = Math.trunc(fracToDouble(modeN));
|
|
const searchN = args.length > 5 ? num(args[5]()) : ONE;
|
|
if (isErr(searchN)) return searchN;
|
|
const reverse = Math.trunc(fracToDouble(searchN)) < 0;
|
|
let best = -1;
|
|
for (let step = 0; step < n; step++) {
|
|
const i = reverse ? n - 1 - step : step;
|
|
const key = at(lookupRange, i);
|
|
if (typeRank(key) !== typeRank(lookup)) continue;
|
|
const c = compareScalars(key, lookup);
|
|
if (c === 0) {
|
|
best = i;
|
|
break;
|
|
}
|
|
if (mode === 1 && c > 0) {
|
|
if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i;
|
|
} else if (mode === -1 && c < 0) {
|
|
if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i;
|
|
}
|
|
}
|
|
if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A");
|
|
return at(returnRange, best);
|
|
},
|
|
},
|
|
};
|