Merge remote-tracking branch 'origin/dev' into sub_laptop_8

This commit is contained in:
2026-09-27 21:03:01 +09:00
31 changed files with 3235 additions and 402 deletions
+148
View File
@@ -109,3 +109,151 @@
background: #24292f;
color: #fff;
}
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
.ss-brush {
flex: none;
padding: 0 8px;
border: none;
border-right: 1px solid #d0d7de;
background: none;
font: inherit;
cursor: pointer;
}
.ss-brush.is-on {
background: #dafbe1;
box-shadow: inset 0 0 0 2px #217346;
}
.ss--painting .ss-grid-host {
cursor: copy;
}
.ss-find {
position: absolute;
top: 4px;
right: 16px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-find[hidden],
.ss-find__row[hidden],
.ss-memo[hidden],
.ss-pick[hidden],
.spreadsheet-comment-balloon[hidden] {
display: none;
}
.ss-find__row {
display: flex;
align-items: center;
gap: 4px;
}
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid #d0d7de;
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid #d0d7de;
border-radius: 4px;
background: #f6f8fa;
font: inherit;
cursor: pointer;
}
.ss-find__opt {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
}
.ss-find__status {
font-size: 12px;
color: #57606a;
}
.ss-memo,
.spreadsheet-comment-balloon {
position: absolute;
z-index: 15;
min-width: 140px;
max-width: 260px;
padding: 4px 6px;
border: 1px solid #bf8700;
background: #fff8c5;
font: inherit;
white-space: pre-wrap;
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
}
.ss-memo {
min-height: 60px;
resize: both;
outline: none;
}
.ss-pick {
position: absolute;
z-index: 20;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid #d0d7de;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid #d0d7de;
background: #f6f8fa;
font: inherit;
cursor: pointer;
}
/* 함수 자동 완성 · 인자 도움말 */
.ss-hint {
position: absolute;
z-index: 6;
min-width: 200px;
max-width: 360px;
border: 1px solid #d0d7de;
background: #fff;
font-size: 12px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-hint[hidden] {
display: none;
}
.ss-hint__item,
.ss-hint__help {
padding: 2px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ss-hint__item {
cursor: pointer;
}
.ss-hint__item.is-on {
background: #ddf4ff;
}
+14 -22
View File
@@ -7,6 +7,7 @@
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E).
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
* ========================================================================== */
import "./spreadsheet.css";
@@ -15,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor } from "./spreadsheet_editor";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
@@ -70,18 +72,6 @@ export interface SpreadsheetHandle {
destroy(): void;
}
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
export const parts = {
createCalcEngine,
createHistory,
applyCommand,
createGrid,
mountToolbar,
mountTabs,
attachMenu,
attachClipboard,
};
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[0];
@@ -117,8 +107,8 @@ export function createSpreadsheet(
root,
book,
readOnly,
engine: parts.createCalcEngine(book),
history: parts.createHistory(),
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
dispatch,
@@ -130,20 +120,22 @@ export function createSpreadsheet(
editor: null as unknown as EditorHandle,
};
const grid = parts.createGrid(ctx);
const grid = createGrid(ctx);
ctx.grid = grid;
const ed = createEditor(ctx);
ctx.editor = ed;
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
const tabs = parts.mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
const clip = parts.attachClipboard(ctx);
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
const tabs = mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
const clip = attachClipboard(ctx);
const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p);
grid.root.classList.add("ss-grid-host");
for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node);
const detachKeys = attachKeys(ctx, ed);
const detachMouse = attachMouse(ctx, ed);
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
@@ -160,7 +152,7 @@ export function createSpreadsheet(
function apply(command: Command): CommandEffect | null {
try {
return parts.applyCommand(ctx.book, command);
return applyCommand(ctx.book, command);
} catch (e) {
if (e instanceof CommandError) return (notify(e.message), null);
throw e;
+18 -68
View File
@@ -18,7 +18,15 @@ import {
rangeToA1,
toA1,
} from "./spreadsheet_address";
import {
addSheet,
moveSheet,
removeSheet,
renameSheet,
replaceSheet,
} from "./spreadsheet_commands_sheet";
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
Cell,
@@ -34,11 +42,11 @@ import type {
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
export class CommandError extends Error {}
type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
export type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
const clone = <T>(x: T): T => structuredClone(x);
export const clone = <T>(x: T): T => structuredClone(x);
function sheetOf(book: Workbook, id: string): Sheet {
export function sheetOf(book: Workbook, id: string): Sheet {
const s = book.시트.find((x) => x.id === id);
if (!s) throw new CommandError(`없는 시트: ${id}`);
return s;
@@ -107,7 +115,7 @@ function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
/** 바뀐 시트만 통째 되돌림 짝 */
function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
export function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
const out: Command[] = [];
for (const o of old) {
const now = book.시트.find((x) => x.id === o.id);
@@ -117,20 +125,12 @@ function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
}
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
function rewriteAll(book: Workbook, command: Command) {
export function rewriteAll(book: Workbook, command: Command) {
for (const s of book.시트)
for (const cell of Object.values(s.칸))
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
}
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
// ── 칸 ────────────────────────────────────────────────────────────────────
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
@@ -332,6 +332,7 @@ function insertDelete(
}
}
shiftFilter(s, rows, insert, at, n);
const key = rows ? "행" : "열";
const frozen = s.틀고정?.[key];
if (frozen && at < frozen)
@@ -484,61 +485,6 @@ function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): Command
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
// ── 시트 ────────────────────────────────────────────────────────────────────
function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
const done: CommandEffect[] = [];
try {
@@ -644,6 +590,10 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect {
return moveSheet(book, command);
case "시트통째":
return replaceSheet(book, command);
case "정렬":
return applySort(book, command);
case "필터":
return applyFilter(book, command);
case "묶음":
return batch(book, command);
}
@@ -0,0 +1,69 @@
/* =============================================================================
* spreadsheet_commands_sheet.ts (주인 A)
* 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands).
* ========================================================================== */
import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import type { Command, CommandEffect, Workbook } from "./spreadsheet_types";
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
export function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
export function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
export function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
export function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
export function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
+104 -2
View File
@@ -11,6 +11,7 @@
import { el } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help";
import { tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
@@ -36,6 +37,10 @@ export interface Editor extends EditorHandle {
point(sel: Selection): void;
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
accept(): boolean;
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
suggestMove(d: number): boolean;
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
commitAll(): void;
/** 초점 되찾기(스크롤 없이) */
@@ -140,7 +145,45 @@ function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): C
return ctx.book.서식[idx] ?? {};
}
/** editorSlot 칸 자리 → 층 안 자리 */
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
export function formulaHint(
pre: string,
): { prefix: string } | { func: string; arg: number } | null {
if (pre[0] !== "=") return null;
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
let depth = 0;
let arg = 0;
let inStr = false;
for (let i = pre.length - 1; i > 0; i--) {
const ch = pre[i];
if (ch === '"') inStr = !inStr;
if (inStr) continue;
if (ch === ")") depth++;
else if (ch === "," && depth === 0) arg++;
else if (ch === "(") {
if (depth-- > 0) continue;
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
return n ? { func: n[1].toUpperCase(), arg } : null;
}
}
return null;
}
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
export function slotOf(
ctx: SpreadsheetContext,
r: number,
c: number,
): { layer: HTMLElement; box: CellBox } {
const { layer, box } = ctx.grid.editorSlot(r, c);
const a = layer.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
}
/** 칸 자리 → 층 안 자리 */
export function boxStyle(node: HTMLElement, box: CellBox): void {
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
@@ -178,7 +221,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
function place(): void {
const at = st?.at ?? ctx.selection.활성;
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
const { layer, box } = slotOf(ctx, at.r, at.c);
if (ta.parentElement !== layer) {
const had = document.activeElement === ta;
layer.append(ta);
@@ -206,6 +249,56 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
function highlight(): void {
const t = st ? text() : "";
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
showHint();
}
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
let sugg: FuncHelp[] = [];
let pick = 0;
let typed = "";
function showHint(): void {
const node = src();
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
sugg = h && "prefix" in h ? suggestFunctions(h.prefix).slice(0, 8) : [];
typed = h && "prefix" in h ? h.prefix : "";
const help = h && "func" in h ? funcHelp(h.func) : null;
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
pick = Math.min(pick, Math.max(0, sugg.length - 1));
hint.replaceChildren(
...(sugg.length
? sugg.map((f, i) => {
const row = el("div", {
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
});
row.addEventListener("mousedown", (e) => {
e.preventDefault();
pick = i;
accept();
});
return row;
})
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
);
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
hint.style.left = ta.style.left;
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
hint.removeAttribute("hidden");
}
function accept(): boolean {
if (!st || !sugg.length) return false;
const node = src();
const pos = node.selectionEnd;
const name = `${sugg[pick].이름}(`;
node.setRangeText(name, pos - typed.length, pos, "end");
(node === ta ? formula : ta).value = node.value;
pick = 0;
place();
highlight();
return true;
}
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
@@ -231,6 +324,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.removeAttribute("style");
ctx.root.classList.remove("ss--editing");
ctx.grid.setRefHighlights([]);
hint.setAttribute("hidden", "");
sync();
focus();
}
@@ -389,11 +483,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
pointable,
point,
pointMove,
accept,
suggestMove(d: number) {
if (!st || !sugg.length) return false;
pick = (pick + d + sugg.length) % sugg.length;
showHint();
return true;
},
focus,
place() {
if (st) place();
},
destroy() {
hint.remove();
ta.remove();
bar.remove();
},
@@ -0,0 +1,208 @@
/* =============================================================================
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import type { Editor } from "./spreadsheet_editor";
import { mountFindPanel } from "./spreadsheet_find_panel";
import { createFormatPainter } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { MouseHooks } from "./spreadsheet_mouse";
import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
};
export interface Extras {
keys: KeyHooks;
mouse: MouseHooks;
parts: PartHandle[];
}
export function attachExtras(
ctx: SpreadsheetContext,
ed: Editor,
toolbarRoot: HTMLElement | null,
): Extras {
const back = (): void => ed.focus();
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
const beside = (r: number, c: number): { x: number; y: number } => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
};
const put = (node: HTMLElement, r: number, c: number): void => {
const p = beside(r, c);
node.style.left = `${p.x}px`;
node.style.top = `${p.y}px`;
};
// ── 찾기 ───────────────────────────────────────────────────────────────
const find = mountFindPanel(ctx, back);
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
const comments = attachComments(ctx);
let hovered = "";
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TEXT.memo } });
ctx.root.append(memo);
let memoAt: { r: number; c: number } | null = null;
const closeMemo = (save: boolean): void => {
if (!memoAt) return;
const at = memoAt;
memoAt = null;
memo.setAttribute("hidden", "");
if (save && memo.value !== (getComment(ctx.sheet(), at.r, at.c) ?? ""))
setComment(ctx, at.r, at.c, memo.value.trim());
back();
};
memo.addEventListener("keydown", (e) => {
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
closeMemo(e.key === "Enter");
}
});
memo.addEventListener("blur", () => closeMemo(true));
function editMemo(): void {
if (ctx.readOnly) return;
const { r, c } = ctx.selection.활성;
memoAt = { r, c };
comments.hideBalloon();
memo.value = getComment(ctx.sheet(), r, c) ?? "";
memo.removeAttribute("hidden");
put(memo, r, c);
memo.focus();
}
// ── 서식 붓 ─────────────────────────────────────────────────────────────
const painter = createFormatPainter(ctx);
let sticky = false;
const brush = el("button", {
className: "ss-brush",
text: "붓",
attrs: { type: "button", title: TEXT.painter },
});
const showBrush = (): void => {
brush.classList.toggle("is-on", painter.active());
ctx.root.classList.toggle("ss--painting", painter.active());
};
brush.addEventListener("click", (e) => {
if (e.detail === 2) sticky = true;
else if (painter.active()) (painter.clear(), (sticky = false));
else (painter.pick(), (sticky = false));
showBrush();
back();
});
if (!ctx.readOnly) ed.bar.prepend(brush);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
ctx.root.append(picker);
const onPaste = (e: ClipboardEvent): void => {
if (!armed || ed.editing()) return;
armed = false;
e.preventDefault();
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
const at = { ...ctx.selection.활성 };
const data = e.clipboardData;
const block = parseClipboard(
data?.getData("text/html") ?? "",
data?.getData("text/plain") ?? "",
at,
);
if (!block) return;
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
const source =
block.출처 === "aislo" && block.원점
? { 시트: ctx.selection.시트, ...block.원점 }
: undefined;
picker.replaceChildren(
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
const b = el("button", {
text: TEXT.modes[mode],
attrs: { type: "button", "data-mode": mode },
});
b.addEventListener("click", () => {
picker.setAttribute("hidden", "");
pasteSpecial(ctx, block, at, mode, source);
back();
});
return b;
}),
);
picker.removeAttribute("hidden");
put(picker, at.r, at.c);
(picker.firstElementChild as HTMLElement).focus();
};
picker.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.preventDefault();
picker.setAttribute("hidden", "");
back();
});
ctx.root.addEventListener("paste", onPaste, true);
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
const onToolbarClick = (e: Event): void => {
if ((e.target as HTMLElement).closest("button")) back();
};
toolbarRoot?.addEventListener("click", onToolbarClick);
toolbarRoot?.addEventListener("change", back);
const self: PartHandle = {
root: null,
refresh: () => showBrush(),
destroy() {
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
memo.remove();
picker.remove();
brush.remove();
},
};
return {
keys: {
find: (replace) => find.open(replace),
pasteSpecial: () => (armed = true),
comment: editMemo,
escape() {
painter.clear();
sticky = false;
showBrush();
},
},
mouse: {
selected() {
if (!painter.active()) return;
painter.paint(ctx.selection.범위[0], sticky);
showBrush();
},
hover(hit) {
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
if (key === hovered) return;
hovered = key;
if (!hit || !key || memoAt || !getComment(ctx.sheet(), hit.r, hit.c))
return comments.hideBalloon();
comments.showBalloon(hit.r, hit.c);
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [find, comments, self],
};
}
@@ -0,0 +1,232 @@
/* =============================================================================
* spreadsheet_filter.ts (주인 A · 화면)
* 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움).
* 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts).
*
* 잇는 법(D · sub7 · C · sub6):
* · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감.
* · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기).
* · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`.
* · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, parseRange, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter";
import type { AutoFilter, Command } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Button: ["필터", "Filter"],
All: ["(모두 선택)", "(Select all)"],
Blank: ["(빈 칸)", "(Blanks)"],
SortAsc: ["오름차순 정렬", "Sort ascending"],
SortDesc: ["내림차순 정렬", "Sort descending"],
Clear: ["이 열 필터 지우기", "Clear filter from column"],
Ok: ["확인", "OK"],
Cancel: ["취소", "Cancel"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
function send(ctx: SpreadsheetContext, make: () => Command): void {
try {
ctx.dispatch(make());
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
}
}
/** 필터 켜기 · 끄기 */
export function toggleFilter(ctx: SpreadsheetContext): void {
if (ctx.readOnly) return;
const s = ctx.sheet();
if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null));
const g = ctx.selection.범위[0];
const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g;
send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] }));
}
export interface FilterHandle extends PartHandle {
/** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */
open(c: number): void;
}
export function attachFilter(ctx: SpreadsheetContext): FilterHandle {
const layer = el("div", { className: "ss-filter__layer" });
let panel: HTMLElement | null = null;
const filterOf = () => {
const f = ctx.sheet().필터;
const rg = f && parseRange(f.범위);
return f && rg ? { f, rg } : null;
};
/** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */
function boxIn(r: number, c: number) {
const { layer: host, box } = ctx.grid.editorSlot(r, c);
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
}
/** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */
function shown(r: number, c: number): boolean {
const v = ctx.grid.visibleRange();
const fz = ctx.sheet().틀고정 ?? {};
const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1);
const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1);
return rowOk && colOk;
}
function place(): void {
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const cur = filterOf();
const btns: HTMLElement[] = [];
if (cur)
for (let c = cur.rg.c0; c <= cur.rg.c1; c++) {
const box = boxIn(cur.rg.r0, c);
if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue;
const on = !!cur.f.조건?.[colName(c)];
const b = el("button", {
className: on ? "ss-filter__btn is-on" : "ss-filter__btn",
text: "▼",
attrs: {
type: "button",
"aria-label": `${t("Button")} ${colName(c)}`,
"data-col": String(c),
},
});
b.style.left = `${box.x + box.w - 17}px`;
b.style.top = `${box.y + box.h - 17}px`;
b.addEventListener("mousedown", (ev) => ev.stopPropagation());
b.addEventListener("click", () => open(c));
btns.push(b);
}
layer.replaceChildren(...btns, ...(panel ? [panel] : []));
}
function close(): void {
panel?.remove();
panel = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (panel && !panel.contains(ev.target as Node)) close();
};
function apply(next: AutoFilter): void {
close();
send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next));
}
function open(c: number): void {
const cur = filterOf();
if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return;
close();
const sheetId = ctx.sheet().id;
const items = filterItems(ctx.book, ctx.engine, sheetId, c);
const boxes = items.map((it) => {
const cb = el("input", { attrs: { type: "checkbox" } });
cb.checked = it.켬;
return cb;
});
const all = el("input", { attrs: { type: "checkbox" } });
const ok = createButton({
label: t("Ok"),
onClick: () => {
const conds = { ...cur.f.조건 };
if (boxes.every((b) => b.checked)) delete conds[colName(c)];
else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글);
apply({ ...cur.f, 조건: conds });
},
});
const sync = () => {
all.checked = boxes.every((b) => b.checked);
all.indeterminate = !all.checked && boxes.some((b) => b.checked);
ok.disabled = !boxes.some((b) => b.checked);
};
all.addEventListener("change", () => {
for (const b of boxes) b.checked = all.checked;
sync();
});
for (const b of boxes) b.addEventListener("change", sync);
const link = (label: string, run: () => void) => {
const a = el("button", {
className: "ss-filter__link",
text: label,
attrs: { type: "button" },
});
a.addEventListener("click", run);
return a;
};
const sortBy = (desc: boolean) => () => {
close();
send(ctx, () =>
sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, {
머리: true,
기준: [{ 열: c, 내림: desc }],
}),
);
};
const rest = { ...cur.f.조건 };
delete rest[colName(c)];
panel = el("div", {
className: "ss-filter__panel",
attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` },
children: [
link(t("SortAsc"), sortBy(false)),
link(t("SortDesc"), sortBy(true)),
link(t("Clear"), () => apply({ ...cur.f, 조건: rest })),
el("div", {
className: "ss-filter__list",
children: [
el("label", { children: [all, t("All")] }),
...items.map((it, i) =>
el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }),
),
],
}),
el("div", {
className: "ss-filter__foot",
children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok],
}),
],
});
panel.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") close();
ev.stopPropagation();
});
const box = boxIn(cur.rg.r0, c);
panel.style.left = `${box.x}px`;
panel.style.top = `${box.y + box.h}px`;
sync();
layer.append(panel);
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh() {
close();
place();
},
open,
destroy() {
close();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
@@ -0,0 +1,131 @@
/* =============================================================================
* spreadsheet_find_panel.ts (주인 D)
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
import { selectCell } from "./spreadsheet_selection";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
find: "찾을 내용",
replace: "바꿀 내용",
next: "다음",
one: "바꾸기",
all: "모두 바꾸기",
close: "닫기",
case: "대소문자",
whole: "전체 칸",
formula: "수식 안",
none: "찾을 수 없음",
replaced: "{n} 개 바꿈",
};
export interface FindPanelHandle extends PartHandle {
open(replace: boolean): void;
close(): void;
}
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
const status = el("span", { className: "ss-find__status" });
const button = (text: string, onClick: () => void): HTMLButtonElement => {
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
b.addEventListener("click", onClick);
return b;
};
const check = (text: string): HTMLInputElement => {
const box = el("input", { attrs: { type: "checkbox" } });
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
return box;
};
const closeBtn = button("×", close);
closeBtn.title = TEXT.close;
const options = el("div", { className: "ss-find__row" });
const caseBox = check(TEXT.case);
const wholeBox = check(TEXT.whole);
const formulaBox = check(TEXT.formula);
const replaceRow = el("div", {
className: "ss-find__row",
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
});
const root = el("div", {
className: "ss-find",
attrs: { hidden: "" },
children: [
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
],
});
ctx.root.append(root);
const opts = (): FindOptions => ({
대소문자: caseBox.checked,
전체칸: wholeBox.checked,
수식안: formulaBox.checked,
});
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
function next(): void {
status.textContent = "";
if (!query.value) return;
const hit = findNext(ctx.book, here(), query.value, opts());
if (!hit) return void (status.textContent = TEXT.none);
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
}
function one(): void {
if (ctx.readOnly || !query.value) return;
const at = here();
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
(h) => h.r === at.r && h.c === at.c,
);
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
next();
}
function all(): void {
if (ctx.readOnly || !query.value) return;
const n = replaceAll(ctx, query.value, repl.value, opts());
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
}
function open(replace: boolean): void {
replaceRow.hidden = !replace || ctx.readOnly;
root.removeAttribute("hidden");
status.textContent = "";
query.focus();
query.select();
}
function close(): void {
root.setAttribute("hidden", "");
back();
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") close();
else if (e.key === "Enter") e.target === repl ? one() : next();
else return;
e.preventDefault();
e.stopPropagation();
};
root.addEventListener("keydown", onKey);
return {
root,
open,
close,
refresh() {},
destroy: () => root.remove(),
};
}
+103 -6
View File
@@ -1,18 +1,21 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` 에 두고 여기서 모음.
* 1단계 약 45 — 수학 SUM PRODUCT SUMPRODUCT ROUND ROUNDUP ROUNDDOWN INT TRUNC CEILING FLOOR ABS MOD
* POWER SQRT PI SIN COS TAN ASIN ACOS ATAN RADIANS DEGREES MIN MAX AVERAGE · 논리 IF AND OR NOT IFERROR ·
* 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE ·
* 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
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";
const TABLE: Record<string, SheetFunction> = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS };
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
return TABLE[name.toUpperCase()];
@@ -22,3 +25,97 @@ export function getFunction(name: string): SheetFunction | undefined {
export function functionNames(): string[] {
return Object.keys(TABLE).sort();
}
/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것.
* `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */
export interface FuncHelpEntry {
이름: string;
꼴: string;
뜻: string;
}
export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" },
{ 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" },
{ 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" },
{ 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" },
{ 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" },
{ 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" },
{ 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" },
{
이름: "SUMIFS",
꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸의 합",
},
{
이름: "COUNTIFS",
꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)",
뜻: "여러 조건에 다 맞는 칸 개수",
},
{
이름: "AVERAGEIF",
꼴: "AVERAGEIF(범위, 조건, [평균범위])",
뜻: "조건에 맞는 칸(또는 짝 칸)의 평균",
},
{
이름: "AVERAGEIFS",
꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 평균",
},
{
이름: "MAXIFS",
꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최댓값",
},
{
이름: "MINIFS",
꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)",
뜻: "여러 조건에 다 맞는 칸의 최솟값",
},
{
이름: "IFS",
꼴: "IFS(조건1, 값1, [조건2, 값2], …)",
뜻: "처음으로 참인 조건의 값",
},
{
이름: "SWITCH",
꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])",
뜻: "식과 같은 값을 찾아 그 결과",
},
{
이름: "XLOOKUP",
꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])",
뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값",
},
{
이름: "TEXTJOIN",
꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)",
뜻: "구분자로 여러 글을 이어 붙임",
},
{ 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" },
{ 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" },
{ 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" },
{
이름: "SUBSTITUTE",
꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])",
뜻: "글 안 옛글을 새글로 바꿈",
},
{ 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" },
{ 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" },
{
이름: "SEARCH",
꼴: "SEARCH(찾을글, 안글, [시작])",
뜻: "찾음(대소문자 안 가림 · * ? 자리표시)",
},
{ 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" },
{ 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" },
{ 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" },
{ 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" },
{ 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" },
{ 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" },
{ 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" },
{ 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" },
{ 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" },
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
@@ -0,0 +1,536 @@
/* =============================================================================
* 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);
},
},
};
@@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record<string, SheetFunction> = {
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),
};
@@ -1,12 +1,12 @@
/* =============================================================================
* spreadsheet_functions_misc.ts (주인 B)
* 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만).
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import {
ZERO,
add,
cmp,
frac,
mul,
@@ -15,12 +15,12 @@ import {
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types";
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
import { formatValue } from "./spreadsheet_numfmt";
import {
bool,
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
@@ -28,82 +28,19 @@ import {
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 {
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
function wildcardToSearchRegex(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);
return new RegExp(escaped, "i");
}
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
@@ -126,8 +63,6 @@ function formatFixed(n: Frac, decimals: number, commas: boolean): string {
return negative ? `-${body}` : body;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
const out: boolean[] = [];
for (const a of args) {
@@ -152,6 +87,23 @@ function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
return out;
}
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
function serialToDate(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
function dateToSerial(d: Date): number {
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
IF: {
min: 2,
@@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
}
},
},
VLOOKUP: {
TEXTJOIN: {
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();
const delim = str(args[0]());
if (isErr(delim)) return delim;
const ignoreEmpty = bool(args[1]());
if (isErr(ignoreEmpty)) return ignoreEmpty;
const parts: string[] = [];
for (let i = 2; i < args.length; i++) {
const v = args[i]();
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++;
for (let c = 0; c < v.cols; c++) {
const t = toText(v.at(r, c));
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
} else if (v !== null) {
count++;
} else {
const t = str(v);
if (isErr(t)) return t;
if (!(ignoreEmpty && t === "")) parts.push(t);
}
}
return frac(BigInt(count));
return parts.join(delim);
},
},
COUNTIF: {
TRIM: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
},
},
UPPER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toUpperCase();
},
},
LOWER: {
min: 1,
max: 1,
call: (args) => {
const s = str(args[0]());
return isErr(s) ? s : s.toLowerCase();
},
},
SUBSTITUTE: {
min: 3,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const oldT = str(args[1]());
if (isErr(oldT)) return oldT;
const newT = str(args[2]());
if (isErr(newT)) return newT;
if (oldT === "") return text;
if (args.length <= 3) return text.split(oldT).join(newT);
const nthArg = num(args[3]());
if (isErr(nthArg)) return nthArg;
const nth = Math.trunc(fracToDouble(nthArg));
let idx = -1;
let count = 0;
let pos = 0;
for (;;) {
const found = text.indexOf(oldT, pos);
if (found < 0) break;
count++;
if (count === nth) {
idx = found;
break;
}
pos = found + oldT.length;
}
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
},
},
REPLACE: {
min: 4,
max: 4,
call: (args) => {
const text = str(args[0]());
if (isErr(text)) return text;
const startN = num(args[1]());
if (isErr(startN)) return startN;
const lenN = num(args[2]());
if (isErr(lenN)) return lenN;
const newText = str(args[3]());
if (isErr(newText)) return newText;
const start = Math.trunc(fracToDouble(startN));
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
if (start < 1) return mkErr("#VALUE!");
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
},
},
FIND: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const idx = within.indexOf(findT, start - 1);
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
},
},
SEARCH: {
min: 2,
max: 3,
call: (args) => {
const findT = str(args[0]());
if (isErr(findT)) return findT;
const within = str(args[1]());
if (isErr(within)) return within;
const startArg = args.length > 2 ? num(args[2]()) : ONE;
if (isErr(startArg)) return startArg;
const start = Math.trunc(fracToDouble(startArg));
if (start < 1) return mkErr("#VALUE!");
const pat = wildcardToSearchRegex(findT);
const m = pat.exec(within.slice(start - 1));
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
},
},
REPT: {
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));
const s = str(args[0]());
if (isErr(s)) return s;
const nArg = num(args[1]());
if (isErr(nArg)) return nArg;
const n = Math.trunc(fracToDouble(nArg));
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
},
},
SUMIF: {
min: 2,
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
TODAY: {
min: 0,
max: 0,
call: () => {
const now = new Date();
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return fromDouble(dateToSerial(new Date(midnight)));
},
},
DATE: {
min: 3,
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;
const y = num(args[0]());
if (isErr(y)) return y;
const m = num(args[1]());
if (isErr(m)) return m;
const d = num(args[2]());
if (isErr(d)) return d;
const date = new Date(
Date.UTC(
Math.trunc(fracToDouble(y)),
Math.trunc(fracToDouble(m)) - 1,
Math.trunc(fracToDouble(d)),
),
);
return fromDouble(dateToSerial(date));
},
},
YEAR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
},
},
MONTH: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
},
},
DAY: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
},
},
};
+33 -3
View File
@@ -31,7 +31,19 @@ const ARROWS: Record<string, [number, number]> = {
ArrowRight: [0, 1],
};
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface KeyHooks {
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
find?(replace: boolean): void;
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
pasteSpecial?(): void;
/** Shift+F2 메모 편집 */
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
}
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
const go = (sel: Selection): void => ctx.select(sel, true);
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
@@ -56,6 +68,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
next(0, e.shiftKey ? -1 : 1);
return true;
@@ -67,6 +80,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
return true;
}
const arrow = ARROWS[e.key];
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
if (!arrow || inBar || ed.mode() === "edit") return false;
if (ed.pointable()) {
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
@@ -116,6 +130,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
case "r":
fill(k === "d");
return true;
case "f":
case "h":
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "v":
if (e.shiftKey) hooks.pasteSpecial?.();
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
}
return false;
}
@@ -126,9 +148,17 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
case "Tab":
next(0, e.shiftKey ? -1 : 1);
return true;
case "F2":
ed.begin();
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
if (!e.shiftKey || !hooks.find) return false;
hooks.find(false);
return true;
case "F2":
if (e.shiftKey) hooks.comment?.();
else ed.begin();
return true;
case "Escape":
hooks.escape?.();
return !!hooks.escape;
case "Delete":
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
return true;
+41 -9
View File
@@ -6,6 +6,7 @@
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
* ========================================================================== */
import { autofitColumn } from "./spreadsheet_autofit";
import { fillCells } from "./spreadsheet_fill";
import {
hasValue,
@@ -18,7 +19,7 @@ import {
selectRows,
selectSpan,
} from "./spreadsheet_selection";
import { boxStyle, type Editor } from "./spreadsheet_editor";
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
import type { CellAddress, CellRange } from "./spreadsheet_types";
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -45,7 +46,19 @@ export function fillTarget(
return { target: { ...src, c0: p.c }, clear: null };
}
export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface MouseHooks {
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
selected?(): void;
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
hover?(hit: HitResult | null): void;
}
export function attachMouse(
ctx: SpreadsheetContext,
ed: Editor,
hooks: MouseHooks = {},
): () => void {
const root = ctx.grid.root;
const preview = document.createElement("div");
preview.className = "ss-fill-preview";
@@ -80,8 +93,8 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
function showPreview(g: CellRange | null): void {
if (!g) return preview.remove();
const a = ctx.grid.editorSlot(g.r0, g.c0);
const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
const a = slotOf(ctx, g.r0, g.c0);
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
if (preview.parentElement !== a.layer) a.layer.append(preview);
boxStyle(preview, {
x: a.box.x,
@@ -198,11 +211,14 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
return drag((p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
});
return drag(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
},
() => hooks.selected?.(),
);
}
}
}
@@ -212,12 +228,28 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
const g = ctx.selection.범위[0];
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++) autofitColumn(ctx, c);
return;
}
if (hit.kind === "cell" && !ed.editing()) ed.begin();
}
const onHover = (e: MouseEvent): void => {
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
};
const onLeave = (): void => hooks.hover?.(null);
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
preview.remove();
root.removeEventListener("mousedown", onDown);
@@ -14,6 +14,7 @@ import {
fracToString,
mul,
roundAt,
sub,
toInteger,
type Frac,
} from "@ui/sheet/ui_template_sheet_frac";
@@ -235,6 +236,130 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string {
return out;
}
// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ───────────────
interface FracPlan {
prefix: Tok[];
wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째)
sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나)
fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정
denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1)
suffix: Tok[];
}
/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */
function detectFraction(toks: Tok[]): FracPlan | null {
if (toks.some((t) => t.k === "point")) return null;
const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/");
if (slashIdx < 0) return null;
let i = slashIdx - 1;
while (i >= 0 && toks[i].k === "digit") i--;
const numeratorStart = i + 1;
if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음
let j = slashIdx + 1;
let denomDigits = 0;
let fixedDigits = "";
let mode: "auto" | "fixed" | null = null;
while (j < toks.length) {
const t = toks[j];
if (t.k === "digit") {
if (mode === "fixed") break;
mode = "auto";
denomDigits++;
j++;
continue;
}
if (t.k === "lit" && /[0-9]/.test(t.ch)) {
if (mode === "auto") break;
mode = "fixed";
fixedDigits += t.ch;
j++;
continue;
}
break;
}
if (mode === null) return null;
const denomEnd = j;
const sepToks: Tok[] = [];
let k = numeratorStart - 1;
while (k >= 0 && toks[k].k !== "digit") {
sepToks.unshift(toks[k]);
k--;
}
let wholeDigits = 0;
while (k >= 0 && toks[k].k === "digit") {
wholeDigits++;
k--;
}
const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart);
if (wholeDigits === 0) sepToks.length = 0;
return {
prefix,
wholeDigits,
sepToks,
fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null,
denomDigits,
suffix: toks.slice(denomEnd),
};
}
/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */
function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } {
let bestNum = 0n;
let bestDen = 1n;
let bestDiff: Frac | null = null;
for (let den = 1n; den <= maxDenom; den++) {
const num = toInteger(mul(x, frac(den)), "round");
const gap = sub(x, frac(num, den));
const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d);
if (bestDiff === null || cmp(diff, bestDiff) < 0) {
bestDiff = diff;
bestNum = num;
bestDen = den;
}
}
return { num: bestNum, den: bestDen };
}
function renderFractionSection(plan: FracPlan, magnitude: Frac): string {
let whole = 0n;
let remainder = magnitude;
if (plan.wholeDigits > 0) {
whole = toInteger(magnitude, "trunc");
remainder = sub(magnitude, frac(whole));
}
let num: bigint;
let den: bigint;
if (plan.fixedDenom !== null) {
den = plan.fixedDenom;
num = toInteger(mul(remainder, frac(den)), "round");
} else {
const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n;
const best = bestFraction(remainder, maxDenom);
num = best.num;
den = best.den;
}
if (plan.wholeDigits > 0 && den > 0n && num >= den) {
whole += num / den;
num %= den;
}
const fracText = num !== 0n ? `${num}/${den}` : "";
let out = "";
for (const t of plan.prefix) if (t.k === "lit") out += t.ch;
if (plan.wholeDigits > 0) {
if (whole !== 0n || fracText === "") out += whole.toString();
if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch;
}
out += fracText;
for (const t of plan.suffix) if (t.k === "lit") out += t.ch;
return out;
}
function generalFormat(value: Frac): string {
return fracToString(value);
}
@@ -265,6 +390,11 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
const fraction = detectFraction(toks);
if (fraction) {
const body = renderFractionSection(fraction, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
@@ -0,0 +1,180 @@
/* =============================================================================
* spreadsheet_sort_dialog.ts (주인 A · 화면)
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
*
* 잇는 법(D · sub7):
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Title: ["정렬", "Sort"],
Range: ["범위", "Range"],
Header: ["머리 행 있음", "My data has headers"],
Case: ["대소문자 구분", "Case sensitive"],
Column: ["열", "Column"],
Asc: ["오름차순", "Ascending"],
Desc: ["내림차순", "Descending"],
AddKey: ["기준 더하기", "Add level"],
RemoveKey: ["빼기", "Remove"],
Cancel: ["취소", "Cancel"],
Ok: ["정렬", "Sort"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
/** 정렬 범위 — 칸 하나면 현재 영역 */
function targetRange(ctx: SpreadsheetContext): CellRange {
const g = ctx.selection.범위[0];
if (g.r0 === g.r1 && g.c0 === g.c1)
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
return g;
}
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
if (rg.r0 === rg.r1) return false;
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
let allText = true;
let secondOther = false;
for (let c = rg.c0; c <= rg.c1; c++) {
const a = v(rg.r0, c);
const b = v(rg.r0 + 1, c);
if (a !== null && typeof a !== "string") allText = false;
if (b !== null && typeof b !== "string") secondOther = true;
}
return allText && secondOther;
}
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
try {
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
return true;
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
return false;
}
}
/** 활성 칸 열 기준 바로 정렬 */
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
if (ctx.readOnly) return;
const rg = targetRange(ctx);
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
}
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
export function openSortDialog(ctx: SpreadsheetContext): () => void {
const rg = targetRange(ctx);
const sheet = ctx.selection.시트;
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
const header = el("input", { attrs: { type: "checkbox" } });
header.checked = guessHeader(ctx, rg);
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
const columnLabel = (c: number) => {
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
};
function drawKeys() {
list.replaceChildren(
...keys.map((k, i) => {
const col = el("select", { attrs: { "aria-label": t("Column") } });
for (let c = rg.c0; c <= rg.c1; c++)
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
col.value = String(k.열);
col.addEventListener("change", () => (k.열 = Number(col.value)));
const dir = el("select", {
children: [
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
],
});
dir.value = k.내림 ? "desc" : "asc";
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
const remove = createButton({
label: t("RemoveKey"),
variant: "ghost",
disabled: keys.length === 1,
onClick: () => {
keys.splice(i, 1);
drawKeys();
},
});
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
}),
);
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const ok = () => {
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
else return;
ev.preventDefault();
ev.stopPropagation();
};
header.addEventListener("change", drawKeys);
box.append(
el("div", { className: "ss-sort__title", text: t("Title") }),
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
list,
el("div", {
className: "ss-sort__row",
children: [
createButton({
label: t("AddKey"),
variant: "ghost",
onClick: () => {
keys.push({ 열: rg.c0 });
drawKeys();
},
}),
],
}),
el("div", {
className: "ss-sort__foot",
children: [
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
createButton({ label: t("Ok"), onClick: ok }),
],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
drawKeys();
ctx.root.append(shade);
return close;
}
@@ -0,0 +1,128 @@
/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */
.ss-sort {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-sort__box,
.ss-filter__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
background: var(--color-surface-raised);
color: var(--color-text-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
font-size: 13px;
}
.ss-sort__box {
min-width: 360px;
max-width: 90%;
}
.ss-sort__title {
font-weight: 600;
}
.ss-sort__meta,
.ss-sort__row,
.ss-sort__foot,
.ss-filter__foot {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.ss-sort__meta {
color: var(--color-text-secondary);
}
.ss-sort__row select {
flex: 1;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: inherit;
}
.ss-sort__foot,
.ss-filter__foot {
justify-content: flex-end;
}
/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */
.ss-filter__layer {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
overflow: visible;
}
.ss-filter__btn {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
font-size: 9px;
line-height: 14px;
text-align: center;
cursor: pointer;
background: var(--color-surface);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-filter__btn.is-on {
color: var(--color-royal-amethyst);
border-color: var(--color-royal-amethyst);
}
.ss-filter__panel {
position: absolute;
z-index: var(--z-dropdown);
width: 220px;
}
.ss-filter__list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow: auto;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
}
.ss-filter__list label {
display: flex;
gap: var(--spacing-4);
align-items: center;
white-space: nowrap;
}
.ss-filter__link {
padding: 2px var(--spacing-4);
text-align: left;
background: none;
border: none;
color: inherit;
cursor: pointer;
}
.ss-filter__link:hover {
background: var(--color-surface-sunken);
}
@@ -0,0 +1,289 @@
/* =============================================================================
* spreadsheet_sortfilter.ts (주인 A)
* 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈.
* 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김.
*
* 엑셀 규칙:
* 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE<TRUE) < 오류 · 내림은 거꾸로 · 빈 칸은 늘 맨 뒤 · 같은 값은 원래 차례.
* 글은 대소문자 가리지 않음(옵션으로 가림 = 소문자 먼저). 값은 계산값(식 칸은 푼 값).
* 정렬 옮김 — 범위 열만 행째 옮김 · 식은 복사처럼 상대 참조만 옮김(범위 밖에서 범위를 가리키던 식은 그대로) ·
* 서식 · 메모도 같이 · 행 높이는 제자리 · 걸러진 행은 제자리(보이는 행끼리만 정렬) · 병합이 걸치면 막음.
* 필터 — 첫 행 = 머리 · 값 목록 = 보이는 글(숫자 형식 적용) · 빈 칸 = "" · 조건 안 맞는 행은 `걸러짐`
* (사용자 숨김과 따로) · 행열을 넣고 지우면 필터 범위 · 조건 열도 따라감 · 끄면 걸러짐 모두 풂.
*
* 잇는 법(D · sub7 · C · sub6):
* · 정렬: 대화 상자 `openSortDialog(ctx)`(spreadsheet_sort_dialog.ts) · 도구 단추 `sortSelection(ctx, 내림)`.
* · 필터: `attachFilter(ctx)`(spreadsheet_filter.ts) 를 부품 목록에 붙이고 grid.render() 뒤 `refresh()` ·
* 도구 단추 · Ctrl+Shift+L = `toggleFilter(ctx)`.
* · 격자(C): 행 `걸러짐` 도 `숨김` 처럼 높이 0 — 머리의 숨김 표시는 사용자 `숨김` 만.
* ========================================================================== */
import { formatValue } from "./spreadsheet_numfmt";
import {
colIndex,
colName,
parseRange,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { clone, CommandError, sheetOf } from "./spreadsheet_commands";
import type { Of } from "./spreadsheet_commands";
import { compareValues, isError, isFrac } from "./spreadsheet_eval";
import { moveFormula, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
AutoFilter,
CalcEngine,
Cell,
CellRange,
Command,
CommandEffect,
Scalar,
Sheet,
Workbook,
} from "./spreadsheet_types";
export interface SortKey {
/** 기준 열(0 부터 · 범위 안) */
열: number;
내림?: boolean;
}
export interface SortOptions {
/** 첫 행 = 머리(정렬 안 함) */
머리: boolean;
기준: SortKey[];
/** 글 대소문자 가림 */
대소문자?: boolean;
}
const filtered = (s: Sheet, r: number) => !!s.행?.[r + 1]?.걸러짐;
const rank = (v: Scalar) =>
isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3;
const blank = (v: Scalar) => v === null || v === "";
/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */
export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number {
if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1;
let d = rank(a) - rank(b);
if (d === 0 && !isError(a)) {
if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string);
else if (typeof a === "string") d = COLLATE.compare(a, b as string);
else d = compareValues(a, b);
}
return desc ? -d : d;
}
const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" });
const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" });
/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */
export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange {
const s = sheetOf(book, sheetId);
const filled = new Set<string>();
for (const [a1, cell] of Object.entries(s.칸))
if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1);
const has = (rr: number, cc: number) =>
rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc));
const g = { r0: r, c0: c, r1: r, c1: c };
for (let grew = true; grew;) {
grew = false;
const rowHas = (rr: number) => {
for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true;
return false;
};
const colHas = (cc: number) => {
for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true;
return false;
};
if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true));
if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true));
if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true));
if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true));
}
return g;
}
function checkMerges(s: Sheet, range: CellRange) {
const hit = (s.병합 ?? []).some((m) => {
const rg = parseRange(m);
return rg && rangesOverlap(rg, range);
});
if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂");
}
// ── 정렬 ───────────────────────────────────────────────────────────────────
/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */
export function sortCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
range: CellRange,
opts: SortOptions,
): Command {
const s = sheetOf(book, sheetId);
const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) };
if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음");
if (!opts.기준.length) throw new CommandError("정렬 기준이 없음");
for (const k of opts.기준)
if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖");
checkMerges(s, data);
const rows: number[] = [];
for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r);
const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))]));
const sorted = [...rows].sort((a, b) => {
const va = keys.get(a)!;
const vb = keys.get(b)!;
for (let i = 0; i < opts.기준.length; i++) {
const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자);
if (d) return d;
}
return a - b;
});
const order: number[] = [];
let next = 0;
for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]);
return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order };
}
export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const { r0, r1, c0, c1 } = cmd.범위;
const n = r1 - r0 + 1;
if (
cmd.차례.length !== n ||
new Set(cmd.차례).size !== n ||
cmd.차례.some((r) => r < r0 || r > r1)
)
throw new CommandError("정렬 차례가 범위와 안 맞음");
checkMerges(s, cmd.범위);
const before = clone(s);
const take = <T>(table: Record<string, T> | undefined) => {
const out = new Map<string, T>();
for (let r = r0; r <= r1; r++)
for (let c = c0; c <= c1; c++) {
const a1 = toA1(r, c);
if (table && a1 in table) {
out.set(a1, table[a1]);
delete table[a1];
}
}
return out;
};
const cells = take<Cell>(s.칸);
const notes = take<string>(s.comments);
cmd.차례.forEach((src, i) => {
const to = r0 + i;
for (let c = c0; c <= c1; c++) {
const cell = cells.get(toA1(src, c));
if (cell) {
const moved = { ...cell };
if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0);
s.칸[toA1(to, c)] = moved;
}
const note = notes.get(toA1(src, c));
if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note;
}
});
if (s.comments && !Object.keys(s.comments).length) delete s.comments;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true };
}
// ── 자동 필터 ──────────────────────────────────────────────────────────────
/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */
export function displayText(
book: Workbook,
engine: CalcEngine,
sheetId: string,
r: number,
c: number,
) {
const s = sheetOf(book, sheetId);
const cell = s.칸[toA1(r, c)];
if (!cell || (cell.값 === undefined && cell.식 === undefined)) return "";
const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0;
return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글;
}
/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */
export function filterItems(
book: Workbook,
engine: CalcEngine,
sheetId: string,
c: number,
): { 글: string; 켬: boolean }[] {
const s = sheetOf(book, sheetId);
const f = s.필터 && parseRange(s.필터.범위);
if (!f) return [];
const allowed = s.필터!.조건?.[colName(c)];
const seen = new Map<string, Scalar>();
for (let r = f.r0 + 1; r <= f.r1; r++) {
const text = displayText(book, engine, sheetId, r, c);
if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c));
}
return [...seen]
.sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) }));
}
/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */
export function filterCommand(
book: Workbook,
engine: CalcEngine,
sheetId: string,
filter: AutoFilter | null,
): Command {
if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] };
const f = parseRange(filter.범위);
if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`);
const hidden: number[] = [];
const conds = Object.entries(filter.조건 ?? {}).map(
([col, list]) => [colIndex(col), new Set(list)] as const,
);
for (let r = f.r0 + 1; r <= f.r1; r++)
if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r);
return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden };
}
export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const before = clone(s);
if (cmd.필터) s.필터 = clone(cmd.필터);
else delete s.필터;
const table = (s.행 ??= {});
for (const [k, info] of Object.entries(table)) {
delete info.걸러짐;
if (!Object.keys(info).length) delete table[k];
}
for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true;
if (!Object.keys(table).length) delete s.행;
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */
export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) {
const f = s.필터 && parseRange(s.필터.범위);
if (!s.필터 || !f) return;
const sp = rows
? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS)
: shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS);
const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n;
if (!sp || headGone) {
delete s.필터;
return;
}
const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] };
s.필터.범위 = rangeToA1(next);
if (rows || !s.필터.조건) return;
const conds: Record<string, string[]> = {};
for (const [col, list] of Object.entries(s.필터.조건)) {
const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS);
if (c) conds[colName(c[0])] = list;
}
s.필터.조건 = conds;
}
+21 -1
View File
@@ -14,7 +14,7 @@
* · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음.
*
* ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 산출근거를 열 때만 받는 조각) ──────
* 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
* 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험).
*
* ── 파일 · 주인 · 예산 줄 수 ─────────────────────────────────────────────────
* 0 spreadsheet_types.ts 이 계약(타입) ~450
@@ -27,6 +27,11 @@
* A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300
* A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550
* A spreadsheet_history.ts 되돌리기 · 다시 ~100
* A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100
* A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
* A spreadsheet_sortfilter.css ~150
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
@@ -125,6 +130,14 @@ export interface RowInfo {
높이?: number;
숨김?: boolean;
서식?: number;
/** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */
걸러짐?: boolean;
}
/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */
export interface AutoFilter {
범위: string;
조건?: Record<string, string[]>;
}
export interface Sheet {
@@ -147,6 +160,8 @@ export interface Sheet {
기본?: { 서식?: number; 열폭?: number; 행높이?: number };
/** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */
comments?: Record<string, string>;
/** 자동 필터(시트에 하나) */
필터?: AutoFilter;
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
@@ -353,6 +368,11 @@ export type Command =
| { 종류: "시트옮기기"; 시트: string; 자리: number }
/** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */
| { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] }
/** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김).
* 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */
| { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] }
/** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */
| { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] }
/** 여러 명령 = 되돌리기 한 번 */
| { 종류: "묶음"; 명령: Command[] };
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>정렬 · 필터 시험 틀</title>
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
<style>
body { margin: 0; font-family: sans-serif; }
#bar { margin: 8px; display: flex; gap: 8px; }
#host { height: 520px; margin: 8px; border: 1px solid #999; }
</style>
</head>
<body>
<div id="bar">
<button id="btn-sort">정렬…</button>
<button id="btn-asc">오름차순</button>
<button id="btn-desc">내림차순</button>
<button id="btn-filter">필터</button>
<button id="btn-undo">되돌리기</button>
</div>
<div id="host"></div>
<script>
window.ssErrors = [];
window.addEventListener("error", (e) => window.ssErrors.push(String(e.error?.stack || e.message)));
</script>
<script src="../../../tmp/spreadsheet_sortfilter/harness.js"></script>
</body>
</html>
@@ -0,0 +1,85 @@
/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음.
* sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
* 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet";
import {
openSortDialog,
sortSelection,
} from "../../../A00_Common/spreadsheet/spreadsheet_sort_dialog";
import { attachFilter, toggleFilter } from "../../../A00_Common/spreadsheet/spreadsheet_filter";
import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
const w = window as unknown as Record<string, unknown>;
let ctx: SpreadsheetContext | null = null;
const origTabs = parts.mountTabs;
parts.mountTabs = (c: SpreadsheetContext) => {
ctx = c;
return origTabs(c);
};
const rows: [string, string, number][] = [
["터파기", "토사", 12.5],
["되메우기", "토사", 3],
["잡석", "", 7.25],
["터파기", "풍화암", 4],
["콘크리트", "18-21-8", 9.9],
["되메우기", "토사", 0],
["거푸집", "합판 3회", 21],
["터파기", "토사", 1.5],
];
const 칸: Workbook["시트"][number]["칸"] = {
A1: { 값: "공종" },
B1: { 값: "규격" },
C1: { 값: "수량" },
D1: { 값: "두 배" },
C12: { 식: "SUM(C2:C9)" },
B12: { 값: "합계" },
};
rows.forEach(([a, b, c], i) => {
const r = i + 2;
칸[`A${r}`] = { 값: a };
if (b) 칸[`B${r}`] = { 값: b };
칸[`C${r}`] = { 값: c, 서식: 1 };
칸[`D${r}`] = { 식: `C${r}*2`, 서식: 1 };
});
const book: Workbook = {
종류: "통합문서",
판: 1,
열: "harness",
서식: [{}, { 형식: "0.00" }],
시트: [{ id: "s1", 이름: "정렬필터", 칸, 행: { 11: { 숨김: true } } }],
활성: "s1",
};
const host = document.getElementById("host")!;
w.ss = createSpreadsheet(host, book, {
onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1),
});
const c = ctx as unknown as SpreadsheetContext;
const filter = attachFilter(c);
const render = c.grid.render.bind(c.grid);
c.grid.render = () => {
render();
filter.refresh();
};
w.ssCtx = c;
w.ssFilter = filter;
w.ssSortDialog = () => openSortDialog(c);
w.ssSortQuick = (desc: boolean) => sortSelection(c, desc);
w.ssToggleFilter = () => toggleFilter(c);
/** 열 값(보이는 칸 글) — 판정용 */
w.ssCol = (letter: string, r0: number, r1: number) => {
const out: string[] = [];
for (let r = r0; r <= r1; r++)
out.push(document.querySelector(`[data-a1="${letter}${r}"]`)?.textContent ?? "");
return out;
};
for (const [id, run] of [
["btn-sort", () => openSortDialog(c)],
["btn-asc", () => sortSelection(c, false)],
["btn-desc", () => sortSelection(c, true)],
["btn-filter", () => toggleFilter(c)],
["btn-undo", () => c.undo()],
] as const)
document.getElementById(id)!.addEventListener("click", run);
@@ -0,0 +1,29 @@
/* 정렬 · 필터 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는 <style> 로 넣음 · @ui 별칭.
* node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "..");
const { build } = require(path.join(ROOT, "config", "node_modules", "rolldown"));
build({
input: path.join(__dirname, "harness_sortfilter.ts"),
resolve: { alias: { "@ui": path.join(ROOT, "ui_template") } },
plugins: [
{
name: "css-inline",
load(id) {
if (!id.endsWith(".css")) return null;
const css = JSON.stringify(fs.readFileSync(id, "utf8"));
const code = `const s = document.createElement("style"); s.textContent = ${css}; document.head.append(s);`;
return { code, moduleType: "js" };
},
},
],
output: { file: path.join(ROOT, "tmp", "spreadsheet_sortfilter", "harness.js"), format: "iife" },
}).then(
() => console.log("ok"),
(e) => {
console.error(e.message);
process.exit(1);
},
);
@@ -93,6 +93,10 @@ const out = {
highlights: ed
.refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null))
.map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`),
// 함수 자동 완성 · 인자 도움말 자리
hint: ["=su", "=A1+ro", "=A1", "=ROUND(B5,", "=SUM(A1,MAX(1,2),", '=IF(A1=",",', "abc"].map((t) =>
ed.formulaHint(t),
),
// 채우기 끌기 목표
fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => {
const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p);
@@ -99,7 +99,7 @@ const H = load("spreadsheet_history");
const RC = load("spreadsheet_recalc");
// 무게 시험(`bench_spreadsheet_engine.cjs`)이 같은 적재 · 가짜 함수를 씀 — 불려 올 때는 시험을 안 돌림
module.exports = { A, C, E, F, G };
module.exports = { A, C, E, F, G, load };
if (require.main !== module) return;
const results = {};
@@ -0,0 +1,206 @@
/* 정렬 · 자동 필터 엔진 시험 헬퍼 — 엑셀 규칙표를 묶음마다 돌려 틀린 것 목록을 JSON 으로 냄.
* 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀.
*/
const { A, C, E, F, G, load } = require("./helper_spreadsheet_engine.cjs");
const SF = load("spreadsheet_sortfilter");
const results = {};
let group = "";
const eq = (label, got, want) => {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
if (g !== w) results[group].push(`${label}: got ${g} want ${w}`);
};
const section = (name, fn) => {
group = name;
results[name] = [];
try {
fn();
} catch (e) {
results[name].push(`예외: ${e && e.stack ? e.stack : e}`);
}
};
const canon = (v) =>
Array.isArray(v)
? v.map(canon)
: v && typeof v === "object"
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])]))
: v;
function book(cells, extra = {}) {
const 칸 = {};
for (const [k, v] of Object.entries(cells))
칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v };
return { 종류: "통합문서", 판: 1, 열: "t", 서식: [{}], 시트: [{ id: "s1", 이름: "본", 칸, ...extra }] };
}
const show = (v) => (v === null ? null : E.isError(v) ? v.error : E.isFrac(v) ? F.fracToString(v) : v);
const col = (b, letter, r0, r1) => {
const e = G.createCalcEngine(b);
const out = [];
for (let r = r0; r <= r1; r++) out.push(show(e.value("s1", r - 1, A.colIndex(letter))));
return out;
};
const R = A.parseRange;
/** 정렬 → 명령 적용 → 되돌림이 처음과 같은지 · 다시 하면 같은 결과인지 */
function sortAndCheck(label, b, range, opts) {
const e = G.createCalcEngine(b);
const cmd = SF.sortCommand(b, e, "s1", R(range), opts);
const before = JSON.stringify(canon(b.시트));
const fx = C.applyCommand(b, cmd);
const after = JSON.stringify(canon(b.시트));
C.applyCommand(b, fx.undo);
eq(`${label} 되돌림`, JSON.stringify(canon(b.시트)) === before, true);
C.applyCommand(b, cmd);
eq(`${label} 다시`, JSON.stringify(canon(b.시트)) === after, true);
return cmd;
}
// ── 1. 정렬 차례 ────────────────────────────────────────────────────────────
section("sort_order", () => {
const mixed = () =>
book({
A1: "값",
B1: "번호",
A2: 3, B2: 1,
A3: "b", B3: 2,
A4: true, B4: 3,
A5: "A", B5: 4,
A6: 1, B6: 5,
B7: 6, // 빈 칸
A8: "=1/0", B8: 7,
A9: false, B9: 8,
A10: "a", B10: 9,
A11: 2, B11: 10,
});
let b = mixed();
sortAndCheck("오름", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }] });
eq("오름 = 수 < 글 < 참거짓 < 오류 < 빈 칸 · 같은 값은 원래 차례", col(b, "B", 2, 11), ["5", "10", "1", "4", "9", "2", "8", "3", "7", "6"]);
eq("머리 행 제자리", col(b, "A", 1, 1), ["값"]);
b = mixed();
sortAndCheck("내림", b, "A1:B11", { 머리: true, 기준: [{ 열: 0, 내림: true }] });
eq("내림 = 거꾸로 · 빈 칸은 그래도 맨 뒤", col(b, "B", 2, 11), ["7", "3", "8", "2", "4", "9", "1", "10", "5", "6"]);
b = mixed();
sortAndCheck("대소문자", b, "A1:B11", { 머리: true, 기준: [{ 열: 0 }], 대소문자: true });
eq("대소문자 가리면 소문자 먼저", col(b, "B", 5, 6), ["9", "4"]);
// 여러 기준 · 머리 없음
b = book({ A1: "나", B1: 1, A2: "가", B2: 2, A3: "나", B3: 3, A4: "가", B4: 1, A5: 10, B5: 9, A6: 9, B6: 9 });
sortAndCheck("여러 기준", b, "A1:B6", { 머리: false, 기준: [{ 열: 0 }, { 열: 1, 내림: true }] });
eq("첫 기준 오름 · 둘째 내림", [col(b, "A", 1, 6), col(b, "B", 1, 6)], [
["9", "10", "가", "가", "나", "나"],
["9", "9", "2", "1", "3", "1"],
]);
eq("현재 영역", SF.currentRegion(mixed(), "s1", 4, 0), R("A1:B11"));
eq("현재 영역 빈 칸 너머는 안 감", SF.currentRegion(book({ A1: 1, A2: 2, C1: 3 }), "s1", 0, 0), R("A1:A2"));
});
// ── 2. 정렬 뒤 수식 · 서식 · 메모 (엑셀 규칙) ────────────────────────────────
section("sort_formulas", () => {
const b = book(
{
A1: 3, B1: "=A1*10", C1: "=$A$1+A1",
A2: 1, B2: "=A2*10", C2: "=$A$1+A2",
A3: 2, B3: "=A3*10", C3: "=$A$1+A3",
E1: "=SUM(A1:A3)", E2: "=A1",
},
{ comments: { A1: "셋", A3: "둘" } },
);
b.시트[0].칸.A2.서식 = 0;
b.서식.push({ 굵게: true });
b.시트[0].칸.A1.서식 = 1;
sortAndCheck("식 옮김", b, "A1:C3", { 머리: false, 기준: [{ 열: 0 }] });
const s = b.시트[0].칸;
eq("상대 참조는 복사처럼 · 절대는 그대로", [s.B1.식, s.B3.식, s.C1.식, s.C3.식], ["A1*10", "A3*10", "$A$1+A1", "$A$1+A3"]);
eq("범위 밖 식은 그대로", [s.E1.식, s.E2.식], ["SUM(A1:A3)", "A1"]);
eq("값", col(b, "B", 1, 3), ["10", "20", "30"]);
eq("E2 는 새 A1 을 봄", col(b, "E", 2, 2), ["1"]);
eq("서식 · 메모 따라감", [s.A3.서식, b.시트[0].comments], [1, { A2: "둘", A3: "셋" }]);
});
// ── 3. 정렬 막힘 · 걸러진 행 제자리 ─────────────────────────────────────────
section("sort_rules", () => {
const b = book({ A1: 3, A2: 1, A3: 2 }, { 병합: ["B2:C2"] });
const e = G.createCalcEngine(b);
let msg = null;
try {
SF.sortCommand(b, e, "s1", R("A1:C3"), { 머리: false, 기준: [{ 열: 0 }] });
} catch (err) {
msg = err instanceof C.CommandError;
}
eq("병합 걸치면 막음", msg, true);
msg = null;
try {
SF.sortCommand(b, e, "s1", R("A1:A3"), { 머리: false, 기준: [{ 열: 3 }] });
} catch (err) {
msg = err instanceof C.CommandError;
}
eq("기준 열이 범위 밖", msg, true);
msg = null;
try {
C.applyCommand(b, { 종류: "정렬", 시트: "s1", 범위: R("A1:A3"), 차례: [0, 0, 1] });
} catch (err) {
msg = err instanceof C.CommandError;
}
eq("차례가 틀리면 막음", msg, true);
const f = book({ A1: 5, A2: 4, A3: 3, A4: 2, A5: 1 }, { 행: { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } } });
sortAndCheck("걸러진 행", f, "A1:A5", { 머리: false, 기준: [{ 열: 0 }] });
eq("보이는 행끼리만 · 걸러진 행 · 높이 제자리", [col(f, "A", 1, 5), f.시트[0].행], [["1", "4", "3", "2", "5"], { 2: { 걸러짐: true }, 4: { 걸러짐: true, 높이: 30 } }]);
});
// ── 4. 자동 필터 ────────────────────────────────────────────────────────────
section("filter", () => {
const make = () => {
const b = book({
A1: "공종", B1: "수량",
A2: "터파기", B2: 1.5,
A3: "되메우기", B3: 0,
A4: "터파기", B4: 2,
B5: 3,
A6: "잡석", B6: "=B2",
D1: "=SUM(B2:B6)",
}, { 행: { 3: { 숨김: true } } });
b.서식.push({ 형식: "0.00" });
for (let r = 2; r <= 6; r++) b.시트[0].칸[`B${r}`].서식 = 1;
return b;
};
const b = make();
let e = G.createCalcEngine(b);
C.applyCommand(b, SF.filterCommand(b, e, "s1", { 범위: "A1:B6" }));
eq("켬 = 걸러짐 없음", [b.시트[0].필터, b.시트[0].행], [{ 범위: "A1:B6" }, { 3: { 숨김: true } }]);
eq("값 목록 · 가나다 · 빈 칸 맨 뒤", SF.filterItems(b, e, "s1", 0), [
{ 글: "되메우기", 켬: true },
{ 글: "잡석", 켬: true },
{ 글: "터파기", 켬: true },
{ 글: "", 켬: true },
]);
eq("수 열 = 보이는 글(형식) · 수 차례", SF.filterItems(b, e, "s1", 1).map((x) => x.글), ["0.00", "1.50", "2.00", "3.00"]);
const on = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""] } });
eq("걸러질 행", on.걸러짐, [2, 5]);
const before = JSON.stringify(canon(b.시트));
const fx = C.applyCommand(b, on);
eq("걸러짐 · 사용자 숨김은 따로", b.시트[0].행, { 3: { 숨김: true, 걸러짐: true }, 6: { 걸러짐: true } });
eq("목록 켬 = 조건", SF.filterItems(b, e, "s1", 0).map((x) => x.켬), [false, false, true, true]);
eq("걸러도 값 · 합계 그대로", col(b, "D", 1, 1), ["8"]);
C.applyCommand(b, fx.undo);
eq("필터 조건 되돌림", JSON.stringify(canon(b.시트)) === before, true);
C.applyCommand(b, on);
const two = SF.filterCommand(b, e, "s1", { 범위: "A1:B6", 조건: { A: ["터파기", ""], B: ["2.00", "3.00"] } });
eq("두 열 조건 = 모두 맞아야", two.걸러짐, [1, 2, 5]);
// 행 넣기 · 열 지우기 때 필터 따라감
C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 3, 수: 1 });
eq("행 넣기 = 범위 늘어남 · 걸러짐 밀림", [b.시트[0].필터.범위, b.시트[0].행["7"]], ["A1:B7", { 걸러짐: true }]);
C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 });
eq("조건 열을 지우면 조건 빠짐", b.시트[0].필터, { 범위: "A1:A7", 조건: {} });
C.applyCommand(b, { 종류: "행지우기", 시트: "s1", at: 0, 수: 1 });
eq("머리 행 지우면 필터 없어짐", b.시트[0].필터, undefined);
// 끄기 — 걸러짐 풀리고 사용자 숨김은 남음
const c = make();
e = G.createCalcEngine(c);
C.applyCommand(c, SF.filterCommand(c, e, "s1", { 범위: "A1:B6", 조건: { A: ["잡석"] } }));
const off = C.applyCommand(c, SF.filterCommand(c, e, "s1", null));
eq("끄기", [c.시트[0].필터, c.시트[0].행], [undefined, { 3: { 숨김: true } }]);
C.applyCommand(c, off.undo);
eq("끄기 되돌림", c.시트[0].행["2"], { 걸러짐: true });
});
process.stdout.write(JSON.stringify(results));
@@ -111,6 +111,9 @@ fracEq(
fracEq("ROUND", call("ROUND", n("1.155"), n(2)), "1.16"); // 분수 반올림 — 부동소수 함정 없음
fracEq("ROUNDUP", call("ROUNDUP", n("1.001"), n(2)), "1.01");
fracEq("ROUNDDOWN", call("ROUNDDOWN", n("1.999"), n(2)), "1.99");
fracEq("ROUND 음수 자릿수(십의 자리)", call("ROUND", n(1234), n(-2)), "1200");
fracEq("ROUNDUP 음수 자릿수", call("ROUNDUP", n(1234), n(-2)), "1300");
fracEq("ROUNDDOWN 음수 자릿수", call("ROUNDDOWN", n(1234), n(-2)), "1200");
fracEq("INT 양수", call("INT", n("8.9")), "8");
fracEq("INT 음수(floor)", call("INT", n("-8.9")), "-9");
fracEq("TRUNC", call("TRUNC", n("8.9")), "8");
@@ -142,6 +145,29 @@ errEq("AVERAGE 빈 값 없음", call("AVERAGE", range(1, 1, [["x"]])), "#DIV/0!"
errEq("MOD 0 나눔", call("MOD", n(1), n(0)), "#DIV/0!");
errEq("CEILING 부호 다름", call("CEILING", n(-2), n(1)), "#NUM!");
// 2단계 첫 묶음(2026-09-27)
fracEq("MROUND", call("MROUND", n(10), n(3)), "9");
fracEq("MROUND 소수", call("MROUND", n("1.3"), n("0.2")), "1.4");
errEq("MROUND 부호 다름", call("MROUND", n(10), n(-3)), "#NUM!");
fracEq("ODD 양수", call("ODD", n("1.5")), "3");
fracEq("ODD 짝수 정수", call("ODD", n(2)), "3");
fracEq("ODD 0", call("ODD", n(0)), "1");
fracEq("ODD 음수", call("ODD", n(-2)), "-3");
fracEq("EVEN 양수", call("EVEN", n("1.5")), "2");
fracEq("EVEN 홀수 정수", call("EVEN", n(3)), "4");
fracEq("EVEN 음수", call("EVEN", n(-1)), "-2");
fracEq("LOG 밑 지정", call("LOG", n(8), n(2)), "3");
fracEq("LOG10", call("LOG10", n(100)), "2");
fracEq("LN(1) = 0", call("LN", n(1)), "0");
ok(
"EXP(1) ≈ e",
(() => {
const v = call("EXP", n(1));
return isFrac(v) && fracToString(v).startsWith("2.71828182845905");
})(),
);
errEq("LOG10 0 이하", call("LOG10", n(0)), "#NUM!");
// ── 논리 · 글 ────────────────────────────────────────────────────────────
fracEq("IF 참 값", call("IF", bl(true), n(1), n(2)), "1");
@@ -161,6 +187,22 @@ textEq("RIGHT", call("RIGHT", txt("가나다"), n(2)), "나다");
textEq("MID", call("MID", txt("가나다라"), n(2), n(2)), "나다");
fracEq("VALUE", call("VALUE", txt("1,234.5")), "1234.5");
errEq("VALUE 실패", call("VALUE", txt("가나다")), "#VALUE!");
textEq(
"TEXTJOIN 빈칸 건너뜀",
call("TEXTJOIN", txt("-"), bl(true), txt("a"), txt(""), txt("b")),
"a-b",
);
textEq("TRIM", call("TRIM", txt(" 가 나 ")), "가 나");
textEq("UPPER", call("UPPER", txt("abc")), "ABC");
textEq("LOWER", call("LOWER", txt("ABC")), "abc");
textEq("SUBSTITUTE 전부", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X")), "XbcXbc");
textEq("SUBSTITUTE 몇번째", call("SUBSTITUTE", txt("abcabc"), txt("a"), txt("X"), n(2)), "abcXbc");
textEq("REPLACE", call("REPLACE", txt("abcdef"), n(2), n(3), txt("XYZ")), "aXYZef");
fracEq("FIND", call("FIND", txt("c"), txt("abcabc")), "3");
errEq("FIND 대소문자 가림", call("FIND", txt("C"), txt("abcabc")), "#VALUE!");
fracEq("SEARCH 대소문자 안가림", call("SEARCH", txt("C"), txt("abcabc")), "3");
fracEq("SEARCH 자리표시(*)", call("SEARCH", txt("a*c"), txt("xxabcyy")), "3");
textEq("REPT", call("REPT", txt("ab"), n(3)), "ababab");
// ── 찾기 ────────────────────────────────────────────────────────────────
@@ -207,6 +249,51 @@ fracEq(
"40",
);
// 2단계 첫 묶음 — 여러 조건 · IFS 계열 · XLOOKUP
const NAMES4 = range(1, 4, [["가", "나", "가", "다"]]);
const AMOUNTS4 = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
const AMOUNTS4B = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("SUMIFS 두 조건", call("SUMIFS", AMOUNTS4, NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "30");
fracEq("COUNTIFS 두 조건", call("COUNTIFS", NAMES4, txt("가"), AMOUNTS4B, txt(">1")), "1");
fracEq("AVERAGEIF", call("AVERAGEIF", AMOUNTS4, txt(">15")), "30");
fracEq("AVERAGEIFS", call("AVERAGEIFS", AMOUNTS4, NAMES4, txt("가")), "20");
fracEq("MAXIFS", call("MAXIFS", AMOUNTS4, NAMES4, txt("가")), "30");
fracEq("MINIFS", call("MINIFS", AMOUNTS4, NAMES4, txt("가")), "10");
fracEq("IFS 처음 참", call("IFS", bl(false), n(1), bl(true), n(2), bl(true), n(3)), "2");
textEq(
"SWITCH 맞음",
call("SWITCH", n(2), n(1), txt("one"), n(2), txt("two"), n(3), txt("three")),
"two",
);
textEq("SWITCH 기본값", call("SWITCH", n(9), n(1), txt("one"), txt("default")), "default");
errEq("SWITCH 안 맞음(기본값 없음)", call("SWITCH", n(9), n(1), txt("one")), "#N/A");
const XNAMES = range(1, 3, [["가", "나", "다"]]);
const XAMOUNTS = range(1, 3, [[frac(10n), frac(20n), frac(30n)]]);
fracEq("XLOOKUP", call("XLOOKUP", txt("나"), XNAMES, XAMOUNTS), "20");
textEq("XLOOKUP 못찾음 대체값", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS, txt("no")), "no");
errEq("XLOOKUP 못찾음 기본", call("XLOOKUP", txt("없음"), XNAMES, XAMOUNTS), "#N/A");
// 정보 · 날짜
boolEq("ISNUMBER 참", call("ISNUMBER", n(5)) as boolean, true);
boolEq("ISNUMBER 거짓(글)", call("ISNUMBER", txt("x")) as boolean, false);
boolEq("ISTEXT 참", call("ISTEXT", txt("x")) as boolean, true);
boolEq("ISBLANK 참", call("ISBLANK", lit(null)) as boolean, true);
boolEq("ISBLANK 거짓(0)", call("ISBLANK", n(0)) as boolean, false);
boolEq("ISERROR 참", call("ISERROR", lit({ error: "#DIV/0!" } as ErrorValue)) as boolean, true);
boolEq("ISERROR 거짓", call("ISERROR", n(1)) as boolean, false);
errEq("NA", call("NA"), "#N/A");
{
const serial = call("DATE", n(2026), n(1), n(15));
fracEq("YEAR(DATE(2026,1,15))", call("YEAR", lit(serial)), "2026");
fracEq("MONTH(DATE(2026,1,15))", call("MONTH", lit(serial)), "1");
fracEq("DAY(DATE(2026,1,15))", call("DAY", lit(serial)), "15");
}
{
const todaySerial = call("TODAY");
const wantYear = new Date().getUTCFullYear();
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -249,6 +336,16 @@ ok(
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)") === "(1.50)",
fmt(frac(-150n, 100n), "0.00_);[Red](0.00)"),
);
// 분수 형식
ok("분수 가분수(자동 분모)", fmt(frac(1n, 2n), "0/0") === "1/2", fmt(frac(1n, 2n), "0/0"));
ok(
"분수 정수부+분자(자동 분모)",
fmt(frac(5n, 2n), "# ?/?") === "2 1/2",
fmt(frac(5n, 2n), "# ?/?"),
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",
@@ -438,7 +438,11 @@ function createGrid(ctx) {
const r0 = Math.floor(root.scrollTop / H);
return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 };
},
editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }),
// C 와 같은 꼴 — box 는 격자 뿌리 기준(스크롤 뺌)
editorSlot: (r, c) => {
const b = cellBox(r, c);
return { layer: content, box: { ...b, x: b.x - root.scrollLeft, y: b.y - root.scrollTop } };
},
destroy: () => root.remove(),
};
}
@@ -1,6 +1,10 @@
// 스프레드시트 크기 재기 — vite 로 줄여 묶은 뒤 gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}}
// 앱 번들에 이미 있는 `@ui/ui_template_elements*` 는 빼고 잼(스프레드시트가 새로 더하는 몫만).
// 스프레드시트 크기 재기 — gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...]}
// engine = 엔진 파일만 줄여 묶은 크기 · total = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
// (스프레드시트 조각 + 끌어오는 공용 조각 + css · 첫 화면 `index.html` 조각은 뺌).
// static = 스프레드시트 조각을 정적으로 끌어오는 조각(비어야 함 — 늦게만 받음).
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gzipSync } from "node:zlib";
@@ -25,7 +29,7 @@ const ENGINE = [
"spreadsheet_fill",
];
async function measure(entries) {
async function engineSize() {
const out = await build({
configFile: false,
logLevel: "silent",
@@ -35,22 +39,60 @@ async function measure(entries) {
write: false,
minify: true,
target: "es2022",
cssCodeSplit: false,
lib: { entry: entries, formats: ["es"] },
rollupOptions: { external: [/ui_template_elements/] },
lib: { entry: ENGINE.map((name) => path.join(dir, `${name}.ts`)), formats: ["es"] },
},
});
const outputs = (Array.isArray(out) ? out : [out]).flatMap((o) => o.output);
const size = { js: 0, css: 0 };
for (const o of outputs) {
const body = o.type === "chunk" ? o.code : o.source;
const gz = gzipSync(typeof body === "string" ? body : Buffer.from(body)).length;
if (o.fileName.endsWith(".css")) size.css += gz;
else if (o.fileName.endsWith(".js")) size.js += gz;
let js = 0;
for (const o of (Array.isArray(out) ? out : [out]).flatMap((o) => o.output)) {
if (o.type === "chunk") js += gzipSync(o.code).length;
}
return size;
return { js, css: 0 };
}
const engine = await measure(ENGINE.map((name) => path.join(dir, `${name}.ts`)));
const total = await measure([path.join(dir, "spreadsheet.ts")]);
console.log(JSON.stringify({ engine, total }));
async function appSize() {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-sheet-size-"));
try {
await build({
configLoader: "runner",
configFile: path.join(root, "vite.config.ts"),
logLevel: "silent",
build: { outDir, emptyOutDir: true, manifest: true },
});
const manifest = JSON.parse(fs.readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8"));
const key = Object.keys(manifest).find((k) => k.endsWith("spreadsheet/spreadsheet.ts"));
const gz = (file) => gzipSync(fs.readFileSync(path.join(outDir, file))).length;
// M02 가 이미 받은 조각은 새 몫이 아님 — 첫 화면 · M02 페이지 · M02 표(ui_template_sheet 등)
// 의 정적 import 사슬. 산출근거 카드는 집계표를 연 뒤에만 열림.
const first = new Set();
const mark = (k) => {
if (first.has(k)) return;
first.add(k);
for (const i of manifest[k].imports ?? []) mark(i);
};
const page = Object.keys(manifest).find((k) => k.endsWith("M02_MasterTemplete_UI_Page.ts"));
mark("index.html");
mark(page);
for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(d);
const size = { js: 0, css: 0 };
const seen = new Set();
const walk = (k) => {
if (seen.has(k) || first.has(k)) return;
seen.add(k);
const e = manifest[k];
size.js += gz(e.file);
for (const c of e.css ?? []) size.css += gz(c);
for (const i of e.imports ?? []) walk(i); // 그 밖 공용 조각은 셈(넉넉히)
};
walk(key);
const statics = Object.entries(manifest)
.filter(([, v]) => (v.imports ?? []).includes(key))
.map(([k]) => k);
return { total: size, static: statics };
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
}
const engine = await engineSize();
const { total, static: statics } = await appSize();
console.log(JSON.stringify({ engine, total, static: statics }));
@@ -81,3 +81,15 @@ def test_fill_target(r):
["B2:B4", "C2:C4"],
["B2:C3", "B4:C4"],
]
def test_formula_hint(r):
assert r["hint"] == [
{"prefix": "su"},
{"prefix": "ro"},
None,
{"func": "ROUND", "arg": 1},
{"func": "SUM", "arg": 2},
{"func": "IF", "arg": 1},
None,
]
@@ -1,7 +1,8 @@
"""F — 스프레드시트 크기 예산 · M02 만 늦게 불러옴(계약 `spreadsheet_types.ts` 머리).
예산(압축 gzip 뒤) — 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB.
엔진은 파일마다 따로 묶어 더하므로 실제보다 조금 크게 잼(넘치면 넘친 것).
예산(압축 gzip 뒤) — 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB.
엔진 = 엔진 파일만 따로 묶은 크기 · 합 = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
(공용 조각까지 넉넉히 셈) · 화면 = 합 − 엔진.
"""
import json
@@ -15,7 +16,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = Path(__file__).with_name("spreadsheet_size.mjs")
KB = 1024
BUDGET = {"engine": 22 * KB, "view": 28 * KB, "css": 4 * KB, "total": 54 * KB}
BUDGET = {"engine": 32 * KB, "view": 28 * KB, "css": 4 * KB, "total": 64 * KB}
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
@@ -36,6 +37,7 @@ def test_크기_예산():
print(
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert size["static"] == [], f"스프레드시트 조각을 정적으로 끌어옴 — {size['static']}"
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""산출근거 스프레드시트 정렬 · 자동 필터 엔진 — 엑셀 규칙표.
정렬 차례(수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 여러 기준 · 대소문자) ·
정렬 뒤 식(복사처럼 상대 참조만 · 범위 밖 식은 그대로) · 서식 · 메모 · 걸러진 행 제자리 · 병합 막음 ·
필터 값 목록(보이는 글 · 빈 칸 맨 뒤) · 걸러짐(사용자 숨김과 따로) · 행열 넣기 · 지우기 때 필터 따라감 · 되돌리기.
화면(대화 상자 · 필터 단추)은 harness_sortfilter.html 을 ORCA 로.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
GROUPS = ["sort_order", "sort_formulas", "sort_rules", "filter"]
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_spreadsheet_sortfilter.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("group", GROUPS)
def test_묶음(results, group):
assert group in results
assert results[group] == []