Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions.ts (주인 B)
|
||||
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
|
||||
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
|
||||
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
|
||||
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
|
||||
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
|
||||
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
|
||||
* 그 파일은 sub1 몫이라 안 고침).
|
||||
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -10,11 +11,13 @@ import type { SheetFunction } from "./spreadsheet_types";
|
||||
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
|
||||
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
|
||||
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
|
||||
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
|
||||
|
||||
const TABLE: Record<string, SheetFunction> = {
|
||||
...MATH_FUNCTIONS,
|
||||
...MISC_FUNCTIONS,
|
||||
...LOOKUP_FUNCTIONS,
|
||||
...STAT_FUNCTIONS,
|
||||
};
|
||||
|
||||
export function getFunction(name: string): SheetFunction | undefined {
|
||||
@@ -119,3 +122,33 @@ export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
|
||||
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
|
||||
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
|
||||
];
|
||||
|
||||
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
|
||||
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
|
||||
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
|
||||
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
|
||||
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
|
||||
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
|
||||
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
|
||||
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
|
||||
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
|
||||
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
|
||||
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
|
||||
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
|
||||
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
|
||||
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
|
||||
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
|
||||
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
|
||||
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
|
||||
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
|
||||
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
|
||||
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
|
||||
{
|
||||
이름: "NETWORKDAYS",
|
||||
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
|
||||
뜻: "평일 일수(토·일 · 휴일 뺌)",
|
||||
},
|
||||
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
|
||||
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
|
||||
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
|
||||
];
|
||||
|
||||
@@ -102,6 +102,58 @@ function dateToSerial(d: Date): number {
|
||||
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
|
||||
}
|
||||
|
||||
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
|
||||
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
|
||||
const start = serialToDate(startSerial);
|
||||
const end = serialToDate(endSerial);
|
||||
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
|
||||
const u = unit.toUpperCase();
|
||||
const sy = start.getUTCFullYear();
|
||||
const sm = start.getUTCMonth();
|
||||
const sd = start.getUTCDate();
|
||||
const ey = end.getUTCFullYear();
|
||||
const em = end.getUTCMonth();
|
||||
const ed = end.getUTCDate();
|
||||
if (u === "Y") {
|
||||
let y = ey - sy;
|
||||
if (em < sm || (em === sm && ed < sd)) y--;
|
||||
return y;
|
||||
}
|
||||
if (u === "M") {
|
||||
let m = (ey - sy) * 12 + (em - sm);
|
||||
if (ed < sd) m--;
|
||||
return m;
|
||||
}
|
||||
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
|
||||
if (u === "YM") {
|
||||
let m = em - sm;
|
||||
if (ed < sd) m--;
|
||||
return ((m % 12) + 12) % 12;
|
||||
}
|
||||
if (u === "MD") {
|
||||
let d = ed - sd;
|
||||
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
|
||||
return d;
|
||||
}
|
||||
if (u === "YD") {
|
||||
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
|
||||
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
|
||||
return days;
|
||||
}
|
||||
return mkErr("#NUM!");
|
||||
}
|
||||
|
||||
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
|
||||
function timeParts(serial: number): { h: number; mnt: number; s: number } {
|
||||
const dayFrac = serial - Math.floor(serial);
|
||||
const totalSeconds = Math.round(dayFrac * 86400);
|
||||
return {
|
||||
h: Math.floor(totalSeconds / 3600) % 24,
|
||||
mnt: Math.floor(totalSeconds / 60) % 60,
|
||||
s: totalSeconds % 60,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
@@ -466,4 +518,139 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
|
||||
},
|
||||
},
|
||||
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
|
||||
WEEKDAY: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
|
||||
if (isErr(typeArg)) return typeArg;
|
||||
const type = Math.trunc(fracToDouble(typeArg));
|
||||
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
|
||||
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
|
||||
if (type === 3) return frac(BigInt((dow + 6) % 7));
|
||||
return frac(BigInt(dow + 1));
|
||||
},
|
||||
},
|
||||
EDATE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const months = Math.trunc(fracToDouble(mArg));
|
||||
const d = serialToDate(fracToDouble(n));
|
||||
const targetMonth = d.getUTCMonth() + months;
|
||||
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
|
||||
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
|
||||
const day = Math.min(d.getUTCDate(), lastDay);
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
EOMONTH: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
if (isErr(n)) return n;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const months = Math.trunc(fracToDouble(mArg));
|
||||
const d = serialToDate(fracToDouble(n));
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
|
||||
return fromDouble(dateToSerial(date));
|
||||
},
|
||||
},
|
||||
DATEDIF: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = num(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const e = num(args[1]());
|
||||
if (isErr(e)) return e;
|
||||
const unit = str(args[2]());
|
||||
if (isErr(unit)) return unit;
|
||||
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
|
||||
return isErr(result) ? result : frac(BigInt(result));
|
||||
},
|
||||
},
|
||||
NETWORKDAYS: {
|
||||
min: 2,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const s = num(args[0]());
|
||||
if (isErr(s)) return s;
|
||||
const e = num(args[1]());
|
||||
if (isErr(e)) return e;
|
||||
let startSerial = Math.round(fracToDouble(s));
|
||||
let endSerial = Math.round(fracToDouble(e));
|
||||
let sign = 1;
|
||||
if (startSerial > endSerial) {
|
||||
[startSerial, endSerial] = [endSerial, startSerial];
|
||||
sign = -1;
|
||||
}
|
||||
const holidays = new Set<number>();
|
||||
if (args.length > 2) {
|
||||
const holidayArg = args[2]();
|
||||
if (isRange(holidayArg)) {
|
||||
for (let r = 0; r < holidayArg.rows; r++) {
|
||||
for (let c = 0; c < holidayArg.cols; c++) {
|
||||
const cell = holidayArg.at(r, c);
|
||||
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
|
||||
}
|
||||
}
|
||||
} else if (isFrac(holidayArg)) {
|
||||
holidays.add(Math.round(fracToDouble(holidayArg)));
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
for (let day = startSerial; day <= endSerial; day++) {
|
||||
const dow = serialToDate(day).getUTCDay();
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
if (holidays.has(day)) continue;
|
||||
count++;
|
||||
}
|
||||
return frac(BigInt(sign * count));
|
||||
},
|
||||
},
|
||||
HOUR: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
|
||||
},
|
||||
},
|
||||
MINUTE: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const n = num(args[0]());
|
||||
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
|
||||
},
|
||||
},
|
||||
TIME: {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (args) => {
|
||||
const hArg = num(args[0]());
|
||||
if (isErr(hArg)) return hArg;
|
||||
const mArg = num(args[1]());
|
||||
if (isErr(mArg)) return mArg;
|
||||
const sArg = num(args[2]());
|
||||
if (isErr(sArg)) return sArg;
|
||||
const totalSeconds =
|
||||
Math.trunc(fracToDouble(hArg)) * 3600 +
|
||||
Math.trunc(fracToDouble(mArg)) * 60 +
|
||||
Math.trunc(fracToDouble(sArg));
|
||||
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
|
||||
return fromDouble(dayFraction / 86400);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
|
||||
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
|
||||
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import type {
|
||||
ErrorValue,
|
||||
EvalContext,
|
||||
EvalResult,
|
||||
LazyArg,
|
||||
RangeValue,
|
||||
SheetFunction,
|
||||
} from "./spreadsheet_types";
|
||||
import {
|
||||
collectNumbers,
|
||||
fracToDouble,
|
||||
fromDouble,
|
||||
isErr,
|
||||
isFrac,
|
||||
isRange,
|
||||
mkErr,
|
||||
num,
|
||||
} from "./spreadsheet_functions_math";
|
||||
|
||||
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
|
||||
const out: Frac[] = [];
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
const cell = range.at(r, c);
|
||||
if (isErr(cell)) return cell;
|
||||
if (isFrac(cell)) out.push(cell);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
|
||||
const n = frac(BigInt(values.length));
|
||||
const mean = div(values.reduce(add, ZERO), n);
|
||||
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
|
||||
return { mean, sumSq };
|
||||
}
|
||||
|
||||
function sampleVariance(values: Frac[]): Frac | ErrorValue {
|
||||
if (values.length < 2) return mkErr("#DIV/0!");
|
||||
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
|
||||
}
|
||||
|
||||
function populationVariance(values: Frac[]): Frac | ErrorValue {
|
||||
if (values.length < 1) return mkErr("#DIV/0!");
|
||||
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
|
||||
}
|
||||
|
||||
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
|
||||
if (isErr(variance)) return variance;
|
||||
return fromDouble(Math.sqrt(fracToDouble(variance)));
|
||||
}
|
||||
|
||||
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
|
||||
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
|
||||
if (values.length === 0) return mkErr("#NUM!");
|
||||
if (k < 0 || k > 1) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
const pos = k * (sorted.length - 1);
|
||||
const lowIdx = Math.floor(pos);
|
||||
const highIdx = Math.ceil(pos);
|
||||
if (lowIdx === highIdx) return sorted[lowIdx];
|
||||
const weight = fromDouble(pos - lowIdx);
|
||||
if (isErr(weight)) return weight;
|
||||
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
|
||||
}
|
||||
|
||||
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
if (k < 1 || k > values.length) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
return sorted[largest ? sorted.length - k : k - 1];
|
||||
}
|
||||
|
||||
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
|
||||
const target = num(args[0]());
|
||||
if (isErr(target)) return target;
|
||||
const range = args[1]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
|
||||
if (isErr(orderArg)) return orderArg;
|
||||
const ascending = cmp(orderArg, ZERO) !== 0;
|
||||
let rank = 1;
|
||||
let found = false;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) {
|
||||
const cell = range.at(r, c);
|
||||
if (!isFrac(cell)) continue;
|
||||
if (cmp(cell, target) === 0) found = true;
|
||||
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
|
||||
}
|
||||
}
|
||||
return found ? frac(BigInt(rank)) : mkErr("#N/A");
|
||||
}
|
||||
|
||||
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
|
||||
MEDIAN: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
if (values.length === 0) return mkErr("#NUM!");
|
||||
const sorted = [...values].sort((a, b) => cmp(a, b));
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[mid];
|
||||
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
|
||||
},
|
||||
},
|
||||
MODE: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
if (isErr(values)) return values;
|
||||
let bestIdx = -1;
|
||||
let bestCount = 1;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
let count = 0;
|
||||
for (const v of values) if (cmp(values[i], v) === 0) count++;
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
|
||||
},
|
||||
},
|
||||
STDEV: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
||||
},
|
||||
},
|
||||
"STDEV.S": {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(sampleVariance(values));
|
||||
},
|
||||
},
|
||||
"STDEV.P": {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : stdevOf(populationVariance(values));
|
||||
},
|
||||
},
|
||||
VAR: {
|
||||
min: 1,
|
||||
call: (args) => {
|
||||
const values = collectNumbers(args, true, true);
|
||||
return isErr(values) ? values : sampleVariance(values);
|
||||
},
|
||||
},
|
||||
RANK: { min: 2, max: 3, call: rankCall },
|
||||
"RANK.EQ": { min: 2, max: 3, call: rankCall },
|
||||
LARGE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
|
||||
},
|
||||
},
|
||||
SMALL: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
|
||||
},
|
||||
},
|
||||
PERCENTILE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const kArg = num(args[1]());
|
||||
if (isErr(kArg)) return kArg;
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
return percentileOf(values, fracToDouble(kArg));
|
||||
},
|
||||
},
|
||||
QUARTILE: {
|
||||
min: 2,
|
||||
max: 2,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
const qArg = num(args[1]());
|
||||
if (isErr(qArg)) return qArg;
|
||||
const q = Math.trunc(fracToDouble(qArg));
|
||||
if (q < 0 || q > 4) return mkErr("#NUM!");
|
||||
const values = rangeNumbers(range);
|
||||
if (isErr(values)) return values;
|
||||
return percentileOf(values, q / 4);
|
||||
},
|
||||
},
|
||||
COUNTBLANK: {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (args) => {
|
||||
const range = args[0]();
|
||||
if (!isRange(range)) return mkErr("#VALUE!");
|
||||
let count = 0;
|
||||
for (let r = 0; r < range.rows; r++) {
|
||||
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
|
||||
}
|
||||
return frac(BigInt(count));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,8 @@
|
||||
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
|
||||
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
|
||||
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
|
||||
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로).
|
||||
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
|
||||
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -364,6 +365,93 @@ function generalFormat(value: Frac): string {
|
||||
return fracToString(value);
|
||||
}
|
||||
|
||||
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
|
||||
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
||||
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
||||
function toDouble(x: Frac): number {
|
||||
return Number(x.n) / Number(x.d);
|
||||
}
|
||||
|
||||
function serialToDateUTC(serial: number): Date {
|
||||
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
||||
}
|
||||
|
||||
type DatePart =
|
||||
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
|
||||
|
||||
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
|
||||
function detectDateFormat(s: string): DatePart[] | null {
|
||||
const parts: DatePart[] = [];
|
||||
let hasDateToken = false;
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') {
|
||||
let j = i + 1;
|
||||
let text = "";
|
||||
while (j < s.length && s[j] !== '"') {
|
||||
text += s[j];
|
||||
j++;
|
||||
}
|
||||
parts.push({ k: "lit", text });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && i + 1 < s.length) {
|
||||
parts.push({ k: "lit", text: s[i + 1] });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const lower = ch.toLowerCase();
|
||||
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
|
||||
let j = i;
|
||||
while (j < s.length && s[j].toLowerCase() === lower) j++;
|
||||
const width = j - i;
|
||||
if (lower === "a" && (width === 3 || width === 4)) {
|
||||
parts.push({ k: "a", width });
|
||||
hasDateToken = true;
|
||||
} else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) {
|
||||
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
|
||||
hasDateToken = true;
|
||||
} else {
|
||||
parts.push({ k: "lit", text: s.slice(i, j) });
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
parts.push({ k: "lit", text: ch });
|
||||
i++;
|
||||
}
|
||||
return hasDateToken ? parts : null;
|
||||
}
|
||||
|
||||
function renderDatePart(part: DatePart, date: Date): string {
|
||||
if (part.k === "lit") return part.text;
|
||||
if (part.k === "y") {
|
||||
const y = date.getUTCFullYear();
|
||||
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
|
||||
}
|
||||
if (part.k === "m") {
|
||||
const m = date.getUTCMonth() + 1;
|
||||
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
|
||||
}
|
||||
if (part.k === "d") {
|
||||
const d = date.getUTCDate();
|
||||
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
|
||||
}
|
||||
const wd = WEEKDAY_SHORT[date.getUTCDay()];
|
||||
return part.width === 3 ? wd : `${wd}요일`;
|
||||
}
|
||||
|
||||
function renderDateSection(parts: DatePart[], value: Frac): string {
|
||||
const date = serialToDateUTC(toDouble(value));
|
||||
return parts.map((p) => renderDatePart(p, date)).join("");
|
||||
}
|
||||
|
||||
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
|
||||
const isNeg = cmp(value, ZERO) < 0;
|
||||
const isZero = cmp(value, ZERO) === 0;
|
||||
@@ -385,6 +473,12 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
|
||||
const sections = splitSections(code);
|
||||
const { code: chosen, forceMinus } = chooseSection(sections, value);
|
||||
const { rest, color } = extractColor(chosen);
|
||||
const datePlan = detectDateFormat(rest);
|
||||
if (datePlan) {
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
|
||||
}
|
||||
const { toks, fillChar } = tokenize(rest);
|
||||
const extra: Partial<FormattedText> = {};
|
||||
if (color) extra.색 = color;
|
||||
|
||||
@@ -294,6 +294,117 @@ errEq("NA", call("NA"), "#N/A");
|
||||
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
|
||||
}
|
||||
|
||||
// ── 통계(3차) ────────────────────────────────────────────────────────────
|
||||
|
||||
fracEq("MEDIAN 홀수", call("MEDIAN", n(1), n(2), n(3), n(4), n(5)), "3");
|
||||
fracEq("MEDIAN 짝수", call("MEDIAN", n(1), n(2), n(3), n(4)), "2.5");
|
||||
fracEq("MODE 최빈값", call("MODE", n(1), n(2), n(2), n(3), n(3), n(3)), "3");
|
||||
errEq("MODE 겹침 없음", call("MODE", n(1), n(2), n(3)), "#N/A");
|
||||
fracEq("VAR 표본분산", call("VAR", n(1), n(2), n(3)), "1");
|
||||
fracEq("STDEV 표본표준편차", call("STDEV", n(1), n(2), n(3)), "1");
|
||||
fracEq("STDEV.S", call("STDEV.S", n(1), n(2), n(3)), "1");
|
||||
fracEq("STDEV.P 모표준편차", call("STDEV.P", n(2), n(4), n(4), n(4), n(5), n(5), n(7), n(9)), "2");
|
||||
{
|
||||
const RANKS = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
|
||||
fracEq("RANK 내림차순(기본)", call("RANK", n(30), RANKS), "2");
|
||||
fracEq("RANK 오름차순", call("RANK", n(30), RANKS, n(1)), "3");
|
||||
fracEq("RANK.EQ", call("RANK.EQ", n(30), RANKS), "2");
|
||||
errEq("RANK 없는 값", call("RANK", n(99), RANKS), "#N/A");
|
||||
fracEq("LARGE 1위", call("LARGE", RANKS, n(1)), "40");
|
||||
fracEq("LARGE 2위", call("LARGE", RANKS, n(2)), "30");
|
||||
fracEq("SMALL 1위", call("SMALL", RANKS, n(1)), "10");
|
||||
errEq("LARGE 범위밖", call("LARGE", RANKS, n(9)), "#NUM!");
|
||||
}
|
||||
{
|
||||
const NUMS4 = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
|
||||
fracEq("PERCENTILE 중간", call("PERCENTILE", NUMS4, n("0.5")), "2.5");
|
||||
fracEq("PERCENTILE 0", call("PERCENTILE", NUMS4, n(0)), "1");
|
||||
fracEq("PERCENTILE 1", call("PERCENTILE", NUMS4, n(1)), "4");
|
||||
fracEq("QUARTILE 중앙값(2)", call("QUARTILE", NUMS4, n(2)), "2.5");
|
||||
fracEq("QUARTILE 최솟값(0)", call("QUARTILE", NUMS4, n(0)), "1");
|
||||
errEq("QUARTILE 범위밖", call("QUARTILE", NUMS4, n(9)), "#NUM!");
|
||||
}
|
||||
fracEq("COUNTBLANK", call("COUNTBLANK", range(1, 4, [[frac(1n), null, "x", null]])), "2");
|
||||
|
||||
// ── 날짜 · 시간(3차) ──────────────────────────────────────────────────────
|
||||
|
||||
function dateSerial(y: number, m: number, day: number): EvalResult {
|
||||
return call("DATE", n(y), n(m), n(day));
|
||||
}
|
||||
|
||||
{
|
||||
const serial = dateSerial(2026, 3, 15);
|
||||
const wantDow = new Date(Date.UTC(2026, 2, 15)).getUTCDay(); // 0=일..6=토
|
||||
fracEq("WEEKDAY 기본(1=일)", call("WEEKDAY", lit(serial)), String(wantDow + 1));
|
||||
fracEq(
|
||||
"WEEKDAY 유형2(1=월)",
|
||||
call("WEEKDAY", lit(serial), n(2)),
|
||||
String(((wantDow + 6) % 7) + 1),
|
||||
);
|
||||
}
|
||||
{
|
||||
// 월말 넘침 — 1/31 + 1개월은 다음 달로 안 넘고 그 달 말일(2/28)로.
|
||||
const edated = call("EDATE", lit(dateSerial(2026, 1, 31)), n(1));
|
||||
fracEq("EDATE 월말 눌러담음(월)", call("MONTH", lit(edated)), "2");
|
||||
fracEq("EDATE 월말 눌러담음(일)", call("DAY", lit(edated)), "28");
|
||||
const normal = call("EDATE", lit(dateSerial(2026, 1, 15)), n(1));
|
||||
fracEq("EDATE 평범한 경우(일)", call("DAY", lit(normal)), "15");
|
||||
}
|
||||
{
|
||||
const eom = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(0));
|
||||
fracEq("EOMONTH 이번달 말일", call("DAY", lit(eom)), "28");
|
||||
const eomNext = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(1));
|
||||
fracEq("EOMONTH 다음달 말일", call("DAY", lit(eomNext)), "31");
|
||||
}
|
||||
fracEq(
|
||||
"DATEDIF Y",
|
||||
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("Y")),
|
||||
"3",
|
||||
);
|
||||
fracEq(
|
||||
"DATEDIF Y(생일 전)",
|
||||
call("DATEDIF", lit(dateSerial(2020, 6, 15)), lit(dateSerial(2023, 1, 1)), txt("Y")),
|
||||
"2",
|
||||
);
|
||||
fracEq(
|
||||
"DATEDIF M",
|
||||
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("M")),
|
||||
"36",
|
||||
);
|
||||
fracEq(
|
||||
"DATEDIF D",
|
||||
call("DATEDIF", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 31)), txt("D")),
|
||||
"30",
|
||||
);
|
||||
errEq(
|
||||
"DATEDIF 끝이 이름(#NUM!)",
|
||||
call("DATEDIF", lit(dateSerial(2024, 1, 10)), lit(dateSerial(2024, 1, 1)), txt("D")),
|
||||
"#NUM!",
|
||||
);
|
||||
{
|
||||
const wantMon = new Date(Date.UTC(2024, 0, 1)).getUTCDay() === 1;
|
||||
ok("2024-01-01 은 월요일(전제)", wantMon);
|
||||
const nw = call("NETWORKDAYS", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 7)));
|
||||
fracEq("NETWORKDAYS 한 주", nw, "5");
|
||||
const nwHoliday = call(
|
||||
"NETWORKDAYS",
|
||||
lit(dateSerial(2024, 1, 1)),
|
||||
lit(dateSerial(2024, 1, 7)),
|
||||
lit(dateSerial(2024, 1, 3)),
|
||||
);
|
||||
fracEq("NETWORKDAYS 휴일 뺌", nwHoliday, "4");
|
||||
}
|
||||
fracEq("HOUR", call("HOUR", lit(call("TIME", n(13), n(45), n(30)))), "13");
|
||||
fracEq("MINUTE", call("MINUTE", lit(call("TIME", n(13), n(45), n(30)))), "45");
|
||||
{
|
||||
const t = call("TIME", n(6), n(0), n(0));
|
||||
ok(
|
||||
"TIME(6,0,0) = 0.25",
|
||||
isFrac(t) && fracToString(t) === "0.25",
|
||||
isFrac(t) ? fracToString(t) : String(t),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 숫자 형식 ────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(value: Scalar, code: string): string {
|
||||
@@ -346,6 +457,21 @@ ok(
|
||||
);
|
||||
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
|
||||
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
|
||||
// 날짜 형식(3차)
|
||||
{
|
||||
const serial = dateSerial(2026, 3, 5) as Scalar;
|
||||
ok("yyyy-mm-dd", fmt(serial, "yyyy-mm-dd") === "2026-03-05", fmt(serial, "yyyy-mm-dd"));
|
||||
ok("m/d(자리 없는 달·일)", fmt(serial, "m/d") === "3/5", fmt(serial, "m/d"));
|
||||
const wd = new Date(Date.UTC(2026, 2, 5)).getUTCDay();
|
||||
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
ok(
|
||||
"요일 aaa",
|
||||
fmt(serial, "aaa") === WEEKDAY_SHORT[wd],
|
||||
`got=${fmt(serial, "aaa")} want=${WEEKDAY_SHORT[wd]}`,
|
||||
);
|
||||
ok("요일 aaaa", fmt(serial, "aaaa") === `${WEEKDAY_SHORT[wd]}요일`, fmt(serial, "aaaa"));
|
||||
}
|
||||
|
||||
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
|
||||
ok(
|
||||
"일반(General)",
|
||||
|
||||
Reference in New Issue
Block a user