/* ============================================================================= * spreadsheet_functions_misc.ts (주인 B) * 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. * 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). * ========================================================================== */ import { ZERO, add, cmp, frac, mul, parseDecimal, roundAt, toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; import { formatValue } from "./spreadsheet_numfmt"; import { bool, collectNumbers, fracToDouble, isErr, isFrac, isRange, mkErr, num, str, toBool, toNumber, toScalar, toText, } 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); } // ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── function groupThousands(digits: string): string { return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); } function formatFixed(n: Frac, decimals: number, commas: boolean): string { const d = Math.max(0, decimals); const rounded = roundAt(n, d, "round"); const negative = cmp(rounded, ZERO) < 0; const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded; const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round"); const digits = scaled.toString().padStart(d + 1, "0"); let whole = d > 0 ? digits.slice(0, -d) : digits; const fraction = d > 0 ? digits.slice(-d) : ""; if (commas) whole = groupThousands(whole); const body = fraction ? `${whole}.${fraction}` : whole; return negative ? `-${body}` : body; } // ── 함수 표 ────────────────────────────────────────────────────────────── function collectBools(args: LazyArg[]): boolean[] | ErrorValue { const out: boolean[] = []; 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)) return cell; if (typeof cell === "boolean") out.push(cell); else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0); } } } else if (isErr(v)) { return v; } else if (v !== null) { const b = toBool(v); if (isErr(b)) return b; out.push(b); } } return out; } export const MISC_FUNCTIONS: Record = { IF: { min: 2, max: 3, call: (args) => { const c = bool(args[0]()); if (isErr(c)) return c; if (c) return args[1](); return args.length > 2 ? args[2]() : false; }, }, AND: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.every(Boolean); }, }, OR: { min: 1, call: (args) => { const bs = collectBools(args); if (isErr(bs)) return bs; if (bs.length === 0) return mkErr("#VALUE!"); return bs.some(Boolean); }, }, NOT: { min: 1, max: 1, call: (args) => { const b = bool(args[0]()); return isErr(b) ? b : !b; }, }, IFERROR: { min: 2, max: 2, call: (args) => { const v = args[0](); if (!isRange(v) && isErr(v)) return args[1](); return v; }, }, CONCATENATE: { min: 1, call: (args) => { let out = ""; 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 t = toText(v.at(r, c)); if (isErr(t)) return t; out += t; } } } else { const t = str(v); if (isErr(t)) return t; out += t; } } return out; }, }, FIXED: { min: 1, max: 3, call: (args) => { const n = num(args[0]()); if (isErr(n)) return n; const decArg = args.length > 1 ? num(args[1]()) : frac(2n); if (isErr(decArg)) return decArg; const decimals = Math.trunc(fracToDouble(decArg)); const noCommaArg = args.length > 2 ? bool(args[2]()) : false; if (isErr(noCommaArg)) return noCommaArg; return formatFixed(n, decimals, !noCommaArg); }, }, TEXT: { min: 2, max: 2, call: (args) => { const v = toScalar(args[0]()); if (isErr(v)) return v; const code = str(args[1]()); if (isErr(code)) return code; return formatValue(v, code).글; }, }, LEN: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); return isErr(s) ? s : frac(BigInt(s.length)); }, }, LEFT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return s.slice(0, n); }, }, RIGHT: { min: 1, max: 2, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const nArg = args.length > 1 ? num(args[1]()) : ONE; if (isErr(nArg)) return nArg; const n = Math.max(0, Math.trunc(fracToDouble(nArg))); return n === 0 ? "" : s.slice(-n); }, }, MID: { min: 3, max: 3, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const startN = num(args[1]()); if (isErr(startN)) return startN; const lenN = num(args[2]()); if (isErr(lenN)) return lenN; const start = Math.trunc(fracToDouble(startN)); const len = Math.max(0, Math.trunc(fracToDouble(lenN))); if (start < 1) return mkErr("#VALUE!"); return s.slice(start - 1, start - 1 + len); }, }, VALUE: { min: 1, max: 1, call: (args) => { const s = str(args[0]()); if (isErr(s)) return s; const trimmed = s.trim(); const pct = trimmed.endsWith("%"); const body = pct ? trimmed.slice(0, -1) : trimmed; try { const v = parseDecimal(body); return pct ? { n: v.n, d: v.d * 100n } : v; } catch { return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`); } }, }, 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; }, }, };