feat(spreadsheet): 정렬 — 엑셀 차례 · 여러 기준 · 머리 행 · 식 복사식 옮김 · 대화 상자 — 계약 더함(정렬 · 필터 명령 · Sheet.필터 · RowInfo.걸러짐)
- 계약 더함: Command 「정렬」(차례를 박음 → 다시 하기가 같음) · 「필터」(걸러짐 행을 박음) · AutoFilter · Sheet.필터 · RowInfo.걸러짐 · 파일 목록 - spreadsheet_sortfilter.ts(A 엔진) — 수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 대소문자 옵션 · 현재 영역 · 정렬 옮김 = 범위 열만 · 식은 상대 참조만 복사처럼 · 범위 밖 식 그대로 · 서식 · 메모 따라감 · 걸러진 행 제자리 · 병합 걸치면 막음 · 자동 필터 엔진(값 목록 · 걸러짐 셈 · 적용 · 행열 넣기 · 지우기 때 필터 따라감) · 되돌림 짝 = 시트통째 - spreadsheet_sort_dialog.ts — 정렬 대화 상자(범위 · 머리 행 짐작 · 기준 더하기 · 오름 · 내림) · 빠른 정렬 sortSelection - spreadsheet_commands_sheet.ts — 시트 명령을 떼어 냄(commands 700 줄 안쪽) - 시험 test_spreadsheet_sortfilter.py 엑셀 규칙표 4 묶음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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] == []
|
||||
Reference in New Issue
Block a user