/* ============================================================================= * 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(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 = { 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); }, }, };