Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_1

This commit is contained in:
2026-09-27 21:18:44 +09:00
36 changed files with 2819 additions and 101 deletions
+49
View File
@@ -257,3 +257,52 @@
.ss-hint__item.is-on {
background: #ddf4ff;
}
/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */
.ss-dialog {
position: absolute;
top: 4px;
right: 16px;
z-index: 21;
max-height: calc(100% - 8px);
overflow: auto;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-dialog[hidden],
.ss-list-arrow[hidden] {
display: none;
}
.ss-dialog__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid #d0d7de;
font-weight: bold;
}
.ss-dialog__body {
padding: 8px;
}
.ss-list-arrow {
position: absolute;
z-index: 8;
width: 16px;
padding: 0;
border: 1px solid #8c959f;
background: #f6f8fa;
font-size: 9px;
cursor: pointer;
}
.ss-validation__dropdown {
position: absolute;
z-index: 20;
}
+2 -1
View File
@@ -74,7 +74,7 @@ export interface SpreadsheetHandle {
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[0];
const g = sel.범위[sel.범위.length - 1];
const a = sel.기준;
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
return {
@@ -222,6 +222,7 @@ export function createSpreadsheet(
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
if (!sheet || sheetId === ctx.selection.시트) return;
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
@@ -0,0 +1,187 @@
/* spreadsheet_analysis.css (주인 A) — 추적 화살표 겹판 · 수식 계산 창 · 오류 ⚠ 단추 */
/* 추적 화살표 — 크기 0 겹판(격자 조작을 막지 않음) */
.ss-trace {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
.ss-trace__line {
stroke-width: 1.5;
}
.ss-trace__line.is-plain,
.ss-trace__dot.is-plain,
.ss-trace__head.is-plain {
stroke: var(--color-info);
fill: var(--color-info);
}
.ss-trace__line.is-error,
.ss-trace__dot.is-error,
.ss-trace__head.is-error {
stroke: var(--color-danger);
fill: var(--color-danger);
}
.ss-trace__line.is-sheet,
.ss-trace__dot.is-sheet,
.ss-trace__head.is-sheet {
stroke: var(--color-text-body);
fill: var(--color-text-body);
}
.ss-trace__line.is-sheet {
stroke-dasharray: 4 3;
}
.ss-trace__box {
fill: none;
stroke-width: 2;
stroke: var(--color-info);
}
.ss-trace__box.is-error {
stroke: var(--color-danger);
}
.ss-trace__sheet {
fill: var(--color-surface);
stroke: var(--color-text-body);
}
/* 수식 계산 창 */
.ss-steps {
position: absolute;
inset: 0;
z-index: var(--z-dropdown);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 18%);
}
.ss-steps__box {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 420px;
max-width: 90%;
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-steps__title {
font-weight: 600;
}
.ss-steps__meta {
color: var(--color-text-secondary);
}
.ss-steps__text {
min-height: 60px;
padding: var(--spacing-8);
font-family: var(--font-mono, monospace);
white-space: pre-wrap;
word-break: break-all;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-steps__foot {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 오류 ⚠ 단추 · 풍선 · 메뉴 */
.ss-errtip {
position: absolute;
z-index: var(--z-dropdown);
width: 0;
height: 0;
overflow: visible;
}
.ss-errtip__btn {
position: absolute;
width: 18px;
height: 18px;
padding: 0;
font-size: 11px;
line-height: 16px;
cursor: pointer;
color: var(--color-warning);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.ss-errtip__balloon {
display: none;
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
width: max-content;
max-width: 320px;
padding: var(--spacing-4) var(--spacing-8);
font-size: 12px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__btn:hover + .ss-errtip__balloon {
display: block;
}
.ss-errtip__menu {
position: absolute;
left: 0;
top: 22px;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 200px;
max-width: 320px;
padding: var(--spacing-4);
font-size: 13px;
color: var(--color-text-body);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.ss-errtip__head {
padding: var(--spacing-4);
font-weight: 600;
}
.ss-errtip__item {
padding: var(--spacing-4);
text-align: left;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
.ss-errtip__item:hover {
background: var(--color-surface-sunken);
}
@@ -0,0 +1,70 @@
/* =============================================================================
* spreadsheet_autosum.ts (3단계 · 주인 sub_laptop_4)
* 자동 합계(Alt+= · 도구 단추 Σ) — 엑셀 규칙: 활성 칸 바로 위가 숫자면 그 위 연속 숫자 칸을
* 위로 죽 잡고, 아니면 바로 왼쪽이 숫자면 왼쪽으로 죽 잡음. 여러 칸을 고른 채면 그 고름
* 안에서(활성 칸 위 또는 왼쪽만) 잡음. 아무것도 못 찾으면 빈 `=SUM()`.
*
* 잇는 법(D · E · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet_keys.ts` 에 Alt+= 를 매핑해 `applyAutoSum(ctx)` 를 부름.
* 2) `spreadsheet_toolbar.ts` 에 Σ 단추를 두고 눌리면 `applyAutoSum(ctx)` 를 부름.
* 3) 지금은 칸에 식 글만 바로 넣음(편집기를 안 거침) — 사용자가 범위를 눈으로 고쳐 잡게 하려면
* `ctx.editor.begin("=SUM(...)")` 로 편집 상태로 열고 커밋은 사용자가 Enter.
* ========================================================================== */
import { rangeToA1, toA1 } from "./spreadsheet_address";
import { isFrac } from "./spreadsheet_functions_math";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean {
if (r < 0 || c < 0) return false;
return isFrac(ctx.engine.value(sheet, r, c));
}
function verticalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
top: number,
): CellRange | null {
if (r - 1 < top || !isNumericCell(ctx, sheet, r - 1, c)) return null;
let start = r - 1;
while (start - 1 >= top && isNumericCell(ctx, sheet, start - 1, c)) start--;
return { r0: start, c0: c, r1: r - 1, c1: c };
}
function horizontalRun(
ctx: SpreadsheetContext,
sheet: string,
r: number,
c: number,
left: number,
): CellRange | null {
if (c - 1 < left || !isNumericCell(ctx, sheet, r, c - 1)) return null;
let start = c - 1;
while (start - 1 >= left && isNumericCell(ctx, sheet, r, start - 1)) start--;
return { r0: r, c0: start, r1: r, c1: c - 1 };
}
/** 활성 칸 기준 합산 범위를 찾음 — 없으면 null(빈 `=SUM()`). */
export function autoSumRange(ctx: SpreadsheetContext): CellRange | null {
const sheet = ctx.selection.시트;
const { r, c } = ctx.selection.활성;
const sel = ctx.selection.범위[0];
if (sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0)) {
if (r > sel.r0) return { r0: sel.r0, c0: c, r1: r - 1, c1: c };
if (c > sel.c0) return { r0: r, c0: sel.c0, r1: r, c1: c - 1 };
}
return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0);
}
/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */
export function applyAutoSum(ctx: SpreadsheetContext): void {
const range = autoSumRange(ctx);
const { r, c } = ctx.selection.활성;
const 식 = range ? `SUM(${rangeToA1(range)})` : "SUM()";
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [toA1(r, c)]: { 식 } } });
}
@@ -0,0 +1,213 @@
/* =============================================================================
* spreadsheet_data_menu.ts (주인 D)
* 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾].
* 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) ·
* 조건부 서식 · 데이터 유효성 · 이름 관리(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 보이기/숨기기.
* 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저).
* ========================================================================== */
import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임)
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el } from "@ui/ui_template_elements";
import { parseRange, toA1 } from "./spreadsheet_address";
import { createCondFmtPanel } from "./spreadsheet_condfmt";
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
import { createNameManagerPanel } from "./spreadsheet_names";
import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
import {
createValidationDropdown,
createValidationPanel,
findValidation,
} from "./spreadsheet_validation";
import type { KeyHooks } from "./spreadsheet_keys";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
asc: "오름차순 정렬(활성 칸 열 기준)",
desc: "내림차순 정렬(활성 칸 열 기준)",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
menu: "데이터 ▾",
sort: "정렬…",
filterItem: "자동 필터 켜기 · 끄기",
condfmt: "조건부 서식…",
validation: "데이터 유효성…",
names: "이름 관리…",
grid: "눈금선 보이기 · 숨기기",
close: "닫기",
list: "값 목록(Alt+↓)",
};
export interface DataMenu {
/** 수식 입력줄 앞에 둘 단추들 */
buttons: HTMLElement[];
keys: Pick<KeyHooks, "filter" | "dropdown">;
part: PartHandle;
}
export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu {
const filter = attachFilter(ctx);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
let closeSort: (() => void) | null = null;
const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run(b);
if (!keep) back();
});
return b;
};
// ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ─────────────────────
const title = el("span", { className: "ss-dialog__title" });
const body = el("div", { className: "ss-dialog__body" });
const shut = el("button", {
className: "ss-find__btn",
text: "×",
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
});
ctx.root.append(dialog);
let panel: PartHandle | null = null;
const closePanel = (): void => {
panel?.destroy();
panel = null;
body.replaceChildren();
dialog.setAttribute("hidden", "");
};
shut.addEventListener("click", () => (closePanel(), back()));
dialog.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.stopPropagation();
closePanel();
back();
});
function openPanel(name: string, make: (c: SpreadsheetContext) => PartHandle): void {
closePanel();
panel = make(ctx);
title.textContent = name.replace("…", "");
if (panel.root) body.append(panel.root);
dialog.removeAttribute("hidden");
}
function openMenu(anchor: HTMLElement): void {
const r = anchor.getBoundingClientRect();
const items: [string, () => void][] = [
[TEXT.sort, () => ((closeSort = openSortDialog(ctx)), undefined)],
[TEXT.filterItem, () => (toggleFilter(ctx), back())],
[TEXT.condfmt, () => openPanel(TEXT.condfmt, createCondFmtPanel)],
[TEXT.validation, () => openPanel(TEXT.validation, createValidationPanel)],
[TEXT.names, () => openPanel(TEXT.names, createNameManagerPanel)],
[TEXT.grid, () => (toggleGridlines(), back())],
];
if (!ctx.readOnly) menu.open(r.left, r.bottom, items);
}
function toggleGridlines(): void {
const s = ctx.sheet();
ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false });
}
// ── 유효성 목록 — ▼ 단추 · Alt+↓ ─────────────────────────────────────────
let drop: HTMLElement | null = null;
const closeDrop = (): void => {
drop?.remove();
drop = null;
};
const arrow = el("button", {
className: "ss-list-arrow",
text: "▼",
attrs: { type: "button", hidden: "", title: TEXT.list },
});
arrow.addEventListener("mousedown", (e) => e.preventDefault());
arrow.addEventListener("click", () => openList());
ctx.root.append(arrow);
/** 칸 자리(부품 뿌리 기준) */
const at = (r: number, c: number) => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h };
};
function openList(): boolean {
const { r, c } = ctx.selection.활성;
const rule = findValidation(ctx.sheet(), r, c);
if (ctx.readOnly || !rule) return false;
closeDrop();
drop = createValidationDropdown(ctx, r, c, rule, (value) => {
closeDrop();
const key = toA1(r, c);
const n = Number(value);
const 값 = value !== "" && Number.isFinite(n) ? n : value;
const 서식 = ctx.sheet().칸[key]?.서식;
const cell = 서식 === undefined ? { 값 } : { 값, 서식 };
ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } });
back();
});
if (!drop) return false;
const p = at(r, c);
drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침
drop.style.top = `${p.y + p.h}px`;
ctx.root.append(drop);
return true;
}
const onOutside = (e: MouseEvent): void => {
if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop();
};
document.addEventListener("mousedown", onOutside, true);
const buttons = ctx.readOnly
? []
: [
button("↑", TEXT.asc, () => sortSelection(ctx, false)),
button("↓", TEXT.desc, () => sortSelection(ctx, true)),
button("필터", TEXT.filter, () => toggleFilter(ctx)),
button(TEXT.menu, TEXT.menu, openMenu, true),
];
return {
buttons,
keys: {
filter: () => toggleFilter(ctx),
dropdown() {
const f = ctx.sheet().필터;
const g = f && parseRange(f.범위);
const { r, c } = ctx.selection.활성;
if (g && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true);
return openList();
},
},
part: {
root: null,
refresh() {
filter.refresh();
panel?.refresh();
closeDrop();
const { r, c } = ctx.selection.활성;
const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록";
arrow.hidden = !list || ctx.editor.editing();
if (!list) return;
const p = at(r, c);
arrow.style.left = `${p.x + p.w + 1}px`;
arrow.style.top = `${p.y}px`;
arrow.style.height = `${p.h}px`;
},
destroy() {
document.removeEventListener("mousedown", onOutside, true);
closeSort?.();
closePanel();
closeDrop();
filter.destroy();
menu.element.remove();
dialog.remove();
arrow.remove();
buttons.forEach((b) => b.remove());
},
},
};
}
+31 -6
View File
@@ -8,12 +8,14 @@
* 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { el, showToast } 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 { defineName, resolveName } from "./spreadsheet_names";
import { tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import { checkValue, findValidation } from "./spreadsheet_validation";
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
import type {
CellBox,
@@ -329,10 +331,12 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
focus();
}
function write(cells: CellAddress[], value: string, from: CellAddress): void {
if (!st) return;
/** 칸에 적음 — 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */
function write(cells: CellAddress[], value: string, from: CellAddress): boolean {
if (!st) return true;
const sheet = sheetOf(st.sheet);
const out: Record<string, Cell | null> = {};
const warn = new Set<string>();
for (const a of cells) {
let v = value;
if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c))
@@ -340,21 +344,29 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
const key = toA1(a.r, a.c);
const next = textToCell(v, sheet.칸[key]);
if (next === null && !sheet.칸[key]) continue;
const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined;
const check = rule && checkValue(rule, next!.값!);
if (check && !check.ok) {
if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false);
warn.add(check.메시지);
}
out[key] = next;
}
for (const m of warn) showToast(m, "warning");
if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out });
return true;
}
function commit(): void {
if (!st) return;
const value = text();
if (value !== st.original) write([st.at], value, st.at);
if (value !== st.original && !write([st.at], value, st.at)) return;
end();
}
function commitAll(): void {
if (!st) return;
write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at);
if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return;
end();
}
@@ -441,6 +453,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
focus();
});
/** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */
function nameBox(name: string): void {
const hit = resolveName(ctx.book, ctx.selection.시트, name);
if (hit) {
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
const at = { r: hit.범위.r0, c: hit.범위.c0 };
return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true);
}
const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]);
if (why) showToast(why, "error");
sync();
}
function goTo(value: string): void {
const bang = value.lastIndexOf("!");
let sheet = ctx.sheet();
@@ -454,7 +479,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
const body = value.slice(bang + 1).replace(/\$/g, "");
const one = parseA1(body);
const g = parseRange(body);
if (!g) return sync();
if (!g) return nameBox(value.trim());
if (one) return ctx.select(selectCell(sheet, one.r, one.c), true);
const at = { r: g.r0, c: g.c0 };
ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true);
@@ -0,0 +1,193 @@
/* =============================================================================
* spreadsheet_errcheck.ts (주인 A)
* 오류 검사(엑셀 초록 세모) — 칸마다 까닭을 냄: 순환 참조 · 식 결과가 오류(#DIV/0! 등) · 숫자처럼 보이는 글.
* 활성 칸에 까닭이 있으면 칸 왼쪽에 ⚠ 단추 · 올리면 까닭 풍선 · 누르면 메뉴(숫자로 변환 · 계산 단계 보기 ·
* 선행 참조 추적 · 오류 무시). 「오류 무시」는 이 화면 동안만(문서에 안 적음).
*
* 잇는 법(C · sub6 · D · sub7):
* · 부품 붙이기: `const tip = attachErrorTip(ctx, trace?)` — ⚠ 단추를 격자 overlay(editorSlot 층)에 얹음.
* grid.render() · 고름 바뀜 뒤마다 `tip.refresh()`. `trace`(spreadsheet_trace 의 손잡이)를 주면 메뉴에 「선행 참조 추적」.
* · 격자(C): 보이는 칸마다 `tip.warning(r, c)` 가 null 이 아니면 칸 왼쪽 위에 초록 세모(6px · `--color-success`).
* 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { toA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import { openEvalSteps } from "./spreadsheet_eval_steps";
import type { TraceHandle } from "./spreadsheet_trace";
import type { CalcEngine, Cell, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
export type WarningKind = "순환" | "오류" | "글숫자";
export interface CellWarning {
종류: WarningKind;
까닭: string;
}
/** 엑셀 오류 값 뜻(엔진 까닭이 없을 때) */
const MEANING: Record<string, [string, string]> = {
"#DIV/0!": ["0 으로 나눔", "Divide by zero"],
"#VALUE!": ["값의 종류가 맞지 않음", "Wrong type of value"],
"#REF!": ["지워진 칸을 가리킴", "Invalid cell reference"],
"#NAME?": ["모르는 이름 · 함수", "Unrecognized name"],
"#N/A": ["찾는 값이 없음", "Value not available"],
"#NUM!": ["수가 맞지 않음", "Invalid number"],
"#NULL!": ["겹치지 않는 범위", "Ranges do not intersect"],
};
const TEXT = {
Cycle: ["순환 참조", "Circular reference"],
TextNumber: ["숫자가 글로 저장됨", "Number stored as text"],
Convert: ["숫자로 변환", "Convert to number"],
Steps: ["계산 단계 보기", "Show calculation steps"],
Trace: ["선행 참조 추적", "Trace precedents"],
Ignore: ["오류 무시", "Ignore error"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
/** 숫자로 읽히는 글(앞뒤 빈칸 · 천 단위 쉼표 · 지수 허용) */
const NUMBER_LIKE = /^[+-]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?(e[+-]?\d+)?$/i;
export function numberLike(text: string): number | null {
const s = text.trim();
if (!/\d/.test(s) || !NUMBER_LIKE.test(s)) return null;
const n = Number(s.replace(/,/g, ""));
return Number.isFinite(n) ? n : null;
}
/** 칸 하나 까닭 — 없으면 null */
export function cellWarning(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): CellWarning | null {
const cell: Cell | undefined = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)];
if (!cell) return null;
if (cell.식 !== undefined) {
const v = engine.value(sheet, r, c);
if (!isError(v)) return null;
if (v.error === "#CYCLE!") return { 종류: "순환", 까닭: v.why ?? t("Cycle") };
const m = MEANING[v.error];
const head = m ? `${v.error} ${m[currentLanguageIndex] ?? m[0]}` : v.error;
return { 종류: "오류", 까닭: v.why ? `${v.error} ${v.why}` : head };
}
if (typeof cell.값 === "string" && numberLike(cell.값) !== null)
return { 종류: "글숫자", 까닭: t("TextNumber") };
return null;
}
export interface ErrorTipHandle extends PartHandle {
/** 격자 세모용 — 「오류 무시」한 칸은 null */
warning(r: number, c: number): CellWarning | null;
}
export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): ErrorTipHandle {
const layer = el("div", { className: "ss-errtip" });
const ignored = new Set<string>();
let menu: HTMLElement | null = null;
const warning = (r: number, c: number) =>
ignored.has(`${ctx.sheet().id}!${toA1(r, c)}`)
? null
: cellWarning(ctx.book, ctx.engine, ctx.sheet().id, r, c);
function closeMenu() {
menu?.remove();
menu = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (ev: MouseEvent) => {
if (menu && !layer.contains(ev.target as Node)) closeMenu();
};
/** 고른 칸 중 글숫자를 모두 수로(서식은 그대로) */
function convert() {
const s = ctx.sheet();
const cells: Record<string, Cell> = {};
for (const g of ctx.selection.범위)
for (let r = g.r0; r <= g.r1; r++)
for (let c = g.c0; c <= g.c1; c++) {
const a1 = toA1(r, c);
const cell = s.칸[a1];
const n =
typeof cell?.값 === "string" && cell.식 === undefined ? numberLike(cell.값) : null;
if (n !== null) cells[a1] = { ...cell, 값: n };
}
if (Object.keys(cells).length) ctx.dispatch({ 종류: "칸", 시트: s.id, 칸: cells });
}
function place() {
closeMenu();
const { r, c } = ctx.selection.활성;
const { layer: host, box } = ctx.grid.editorSlot(r, c);
if (layer.parentElement !== host) host.append(layer);
const w = warning(r, c);
const v = ctx.grid.visibleRange();
if (!w || !box.w || r < v.r0 || r > v.r1 || c < v.c0 || c > v.c1) {
layer.replaceChildren();
return;
}
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
layer.style.left = `${box.x - (a.left - g.left) - 20}px`;
layer.style.top = `${box.y - (a.top - g.top)}px`;
const btn = el("button", {
className: "ss-errtip__btn",
text: "⚠",
attrs: { type: "button", "aria-label": w.까닭 },
});
const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 });
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c)));
layer.replaceChildren(btn, balloon);
}
function openMenu(w: CellWarning, r: number, c: number) {
const item = (label: string, run: () => void) => {
const b = el("button", {
className: "ss-errtip__item",
text: label,
attrs: { type: "button" },
});
b.addEventListener("click", () => {
closeMenu();
run();
});
return b;
};
const items: HTMLElement[] = [el("div", { className: "ss-errtip__head", text: w.까닭 })];
if (w.종류 === "글숫자" && !ctx.readOnly) items.push(item(t("Convert"), convert));
if (w.종류 !== "글숫자") items.push(item(t("Steps"), () => openEvalSteps(ctx)));
if (w.종류 !== "글숫자" && trace) items.push(item(t("Trace"), () => trace.precedents()));
items.push(
item(t("Ignore"), () => {
ignored.add(`${ctx.sheet().id}!${toA1(r, c)}`);
ctx.grid.render();
place();
}),
);
menu = el("div", { className: "ss-errtip__menu", attrs: { role: "menu" }, children: items });
layer.append(menu);
document.addEventListener("mousedown", onOutside, true);
}
const onScroll = () => place();
ctx.grid.root.addEventListener("scroll", onScroll, true);
place();
return {
root: layer,
refresh: place,
warning,
destroy() {
closeMenu();
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
@@ -0,0 +1,261 @@
/* =============================================================================
* spreadsheet_eval_steps.ts (주인 A)
* 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌.
* 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 ·
* IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로.
* 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만.
*
* 잇는 법(D · sub7):
* · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { isError, isRange, numberText } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { evalSafe } from "./spreadsheet_graph";
import { formulaToText, parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CellRange,
EvalContext,
FormulaNode,
Scalar,
Workbook,
} from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_analysis.css";
/** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */
export interface EvalStep {
글: string;
밑줄: [number, number] | null;
}
/** 첫 인자만 먼저 풂 */
const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]);
/** 칸 참조 인자를 값으로 안 바꿈 */
const REF_ARGS = new Set([
"ROW",
"COLUMN",
"ROWS",
"COLUMNS",
"OFFSET",
"INDEX",
"ISREF",
"ISBLANK",
"CELL",
"ISFORMULA",
"FORMULATEXT",
"SUMIF",
"SUMIFS",
"COUNTIF",
"COUNTIFS",
"AVERAGEIF",
"AVERAGEIFS",
"COUNTBLANK",
]);
/** 푸는 차례(왼쪽 먼저 · 아래 먼저) */
function order(node: FormulaNode, out: FormulaNode[], refArg = false): void {
switch (node.type) {
case "cell":
if (!refArg) out.push(node);
return;
case "name":
out.push(node);
return;
case "unary":
if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로
order(node.arg, out);
out.push(node);
return;
case "binary":
order(node.left, out);
order(node.right, out);
out.push(node);
return;
case "call": {
const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args;
for (const a of args) order(a, out, REF_ARGS.has(node.name));
out.push(node);
}
}
}
export function valueText(v: Scalar): string {
if (v === null) return "0";
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
if (isError(v)) return v.error;
return numberText(v);
}
/** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */
function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) {
const ext = new Map<string, { r: number; c: number }>();
const extent = (id: string) => {
let e = ext.get(id);
if (!e) {
e = { r: 0, c: 0 };
for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) {
const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null;
if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) };
}
ext.set(id, e);
}
return e;
};
const ctx: EvalContext = {
sheet,
r,
c,
cell: (s, rr, cc) => engine.value(s, rr, cc),
range(s, rg: CellRange): PlacedRange {
const e = extent(s);
const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r));
const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j),
...rg,
};
},
sheetId: (name) =>
book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null,
};
return ctx;
}
/** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */
export function evalSteps(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): EvalStep[] | null {
const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식;
if (text === undefined) return null;
const p = parseFormula(text);
if (!p.ok)
return [
{ 글: text, 밑줄: null },
{ 글: valueText(engine.value(sheet, r, c)), 밑줄: null },
];
const ctx = contextFor(book, engine, sheet, r, c);
const nodes: FormulaNode[] = [];
order(p.node, nodes);
// 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차)
if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node);
const vals = new Map<FormulaNode, Scalar>();
const steps: FormulaNode[] = [];
for (const n of nodes) {
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
else {
const v = evalSafe(n, ctx);
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
vals.set(n, v);
}
steps.push(n);
}
const done = new Map<FormulaNode, string>();
const out: EvalStep[] = [];
for (let i = 0; i <= steps.length; i++) {
const raw = formulaToText(p.node, (n) => done.get(n), steps[i]);
const a = raw.indexOf("\u0001");
const b = raw.indexOf("\u0002");
out.push(
a < 0
? { 글: raw, 밑줄: null }
: { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] },
);
if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null));
}
return out;
}
// ── 창 ──────────────────────────────────────────────────────────────────────
const TEXT = {
Title: ["수식 계산", "Evaluate Formula"],
Ref: ["참조", "Reference"],
Hint: [
"밑줄 친 식의 값을 보려면 [계산] 을 누름",
"To show the result of the underlined expression, click Evaluate.",
],
Eval: ["계산", "Evaluate"],
Restart: ["다시 시작", "Restart"],
Close: ["닫기", "Close"],
NoFormula: ["식이 없는 칸", "The cell has no formula"],
};
const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0];
export function openEvalSteps(ctx: SpreadsheetContext): () => void {
const sheet = ctx.sheet();
const { r, c } = ctx.selection.활성;
const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c);
if (!steps) {
showToast(t("NoFormula"), "info");
return () => {};
}
let i = 0;
const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } });
const next = createButton({
label: t("Eval"),
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
function go(to: number) {
i = to;
const s = steps![i];
if (!s.밑줄) view.replaceChildren(s.글);
else {
const [a, b] = s.밑줄;
view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b));
}
next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart");
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter") next.click();
else return;
ev.preventDefault();
ev.stopPropagation();
};
box.append(
el("div", { className: "ss-steps__title", text: t("Title") }),
el("div", {
className: "ss-steps__meta",
text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`,
}),
view,
el("div", { className: "ss-steps__meta", text: t("Hint") }),
el("div", {
className: "ss-steps__foot",
children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
go(0);
ctx.root.append(shade);
next.focus();
return close;
}
+31 -48
View File
@@ -1,34 +1,30 @@
/* =============================================================================
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓).
* 셀 서식 창(Ctrl+1) · 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { parseRange } from "./spreadsheet_address";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import { mountDataMenu } from "./spreadsheet_data_menu";
import type { Editor } from "./spreadsheet_editor";
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
import { mountFindPanel } from "./spreadsheet_find_panel";
import { openFormatDialog } from "./spreadsheet_format_dialog";
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 { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
format: "셀 서식(Ctrl+1)",
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
sortAsc: "오름차순 정렬(활성 칸 열 기준)",
sortDesc: "내림차순 정렬(활성 칸 열 기준)",
sortDialog: "정렬 — 기준 여럿 · 머리 행",
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
};
export interface Extras {
@@ -113,34 +109,29 @@ export function attachExtras(
showBrush();
back();
});
if (!ctx.readOnly) ed.bar.prepend(brush);
// ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ───────────────────────
const filter = attachFilter(ctx);
let closeSort: (() => void) | null = null;
const dataButton = (text: string, title: string, run: () => void, dialog = false) => {
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
b.addEventListener("click", () => {
run();
if (!dialog) back();
// 셀 서식 창 — Ctrl+1 을 브라우저 · ORCA 가 가로챌 때 누를 자리
const fmt = el("button", {
className: "ss-brush",
text: "서식…",
attrs: { type: "button", title: TEXT.format },
});
/** 창이 닫히면(확인 · 취소) 초점을 격자로 */
function openFormat(): void {
if (ctx.readOnly) return;
openFormatDialog(ctx);
const watch = new MutationObserver(() => {
if (document.querySelector(".ss-fmtdlg")) return;
watch.disconnect();
back();
});
return b;
};
const data = [
dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)),
dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)),
dataButton(
"정렬…",
TEXT.sortDialog,
() => {
closeSort?.();
closeSort = openSortDialog(ctx);
},
true,
),
dataButton("필터", TEXT.filter, () => toggleFilter(ctx)),
];
if (!ctx.readOnly) brush.after(...data);
watch.observe(document.body, { childList: true });
}
fmt.addEventListener("click", openFormat);
if (!ctx.readOnly) ed.bar.prepend(fmt, brush);
// ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ─────────
const data = mountDataMenu(ctx, back);
brush.after(...data.buttons);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
@@ -201,14 +192,13 @@ export function attachExtras(
root: null,
refresh: () => showBrush(),
destroy() {
closeSort?.();
data.forEach((b) => b.remove());
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
memo.remove();
picker.remove();
brush.remove();
fmt.remove();
},
};
@@ -217,15 +207,8 @@ export function attachExtras(
find: (replace) => find.open(replace),
pasteSpecial: () => (armed = true),
comment: editMemo,
filter: () => toggleFilter(ctx),
dropdown() {
const f = ctx.sheet().필터;
const g = f && parseRange(f.범위);
const { r, c } = ctx.selection.활성;
if (!g || r !== g.r0 || c < g.c0 || c > g.c1) return false;
filter.open(c);
return true;
},
formatDialog: openFormat,
...data.keys,
escape() {
painter.clear();
sticky = false;
@@ -248,6 +231,6 @@ export function attachExtras(
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [find, comments, filter, self],
parts: [find, comments, data.part, self],
};
}
@@ -1,8 +1,9 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
* 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
@@ -10,11 +11,13 @@ import type { SheetFunction } from "./spreadsheet_types";
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
...STAT_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
@@ -119,3 +122,33 @@ export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
{
이름: "NETWORKDAYS",
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
뜻: "평일 일수(토·일 · 휴일 뺌)",
},
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
];
@@ -102,6 +102,58 @@ function dateToSerial(d: Date): number {
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
}
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
const start = serialToDate(startSerial);
const end = serialToDate(endSerial);
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
const u = unit.toUpperCase();
const sy = start.getUTCFullYear();
const sm = start.getUTCMonth();
const sd = start.getUTCDate();
const ey = end.getUTCFullYear();
const em = end.getUTCMonth();
const ed = end.getUTCDate();
if (u === "Y") {
let y = ey - sy;
if (em < sm || (em === sm && ed < sd)) y--;
return y;
}
if (u === "M") {
let m = (ey - sy) * 12 + (em - sm);
if (ed < sd) m--;
return m;
}
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
if (u === "YM") {
let m = em - sm;
if (ed < sd) m--;
return ((m % 12) + 12) % 12;
}
if (u === "MD") {
let d = ed - sd;
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
return d;
}
if (u === "YD") {
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
return days;
}
return mkErr("#NUM!");
}
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
function timeParts(serial: number): { h: number; mnt: number; s: number } {
const dayFrac = serial - Math.floor(serial);
const totalSeconds = Math.round(dayFrac * 86400);
return {
h: Math.floor(totalSeconds / 3600) % 24,
mnt: Math.floor(totalSeconds / 60) % 60,
s: totalSeconds % 60,
};
}
// ── 함수 표 ──────────────────────────────────────────────────────────────
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
@@ -466,4 +518,139 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
},
},
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
WEEKDAY: {
min: 1,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
if (isErr(typeArg)) return typeArg;
const type = Math.trunc(fracToDouble(typeArg));
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
if (type === 3) return frac(BigInt((dow + 6) % 7));
return frac(BigInt(dow + 1));
},
},
EDATE: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const targetMonth = d.getUTCMonth() + months;
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
const day = Math.min(d.getUTCDate(), lastDay);
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
return fromDouble(dateToSerial(date));
},
},
EOMONTH: {
min: 2,
max: 2,
call: (args) => {
const n = num(args[0]());
if (isErr(n)) return n;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const months = Math.trunc(fracToDouble(mArg));
const d = serialToDate(fracToDouble(n));
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
return fromDouble(dateToSerial(date));
},
},
DATEDIF: {
min: 3,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
const unit = str(args[2]());
if (isErr(unit)) return unit;
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
return isErr(result) ? result : frac(BigInt(result));
},
},
NETWORKDAYS: {
min: 2,
max: 3,
call: (args) => {
const s = num(args[0]());
if (isErr(s)) return s;
const e = num(args[1]());
if (isErr(e)) return e;
let startSerial = Math.round(fracToDouble(s));
let endSerial = Math.round(fracToDouble(e));
let sign = 1;
if (startSerial > endSerial) {
[startSerial, endSerial] = [endSerial, startSerial];
sign = -1;
}
const holidays = new Set<number>();
if (args.length > 2) {
const holidayArg = args[2]();
if (isRange(holidayArg)) {
for (let r = 0; r < holidayArg.rows; r++) {
for (let c = 0; c < holidayArg.cols; c++) {
const cell = holidayArg.at(r, c);
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
}
}
} else if (isFrac(holidayArg)) {
holidays.add(Math.round(fracToDouble(holidayArg)));
}
}
let count = 0;
for (let day = startSerial; day <= endSerial; day++) {
const dow = serialToDate(day).getUTCDay();
if (dow === 0 || dow === 6) continue;
if (holidays.has(day)) continue;
count++;
}
return frac(BigInt(sign * count));
},
},
HOUR: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
},
},
MINUTE: {
min: 1,
max: 1,
call: (args) => {
const n = num(args[0]());
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
},
},
TIME: {
min: 3,
max: 3,
call: (args) => {
const hArg = num(args[0]());
if (isErr(hArg)) return hArg;
const mArg = num(args[1]());
if (isErr(mArg)) return mArg;
const sArg = num(args[2]());
if (isErr(sArg)) return sArg;
const totalSeconds =
Math.trunc(fracToDouble(hArg)) * 3600 +
Math.trunc(fracToDouble(mArg)) * 60 +
Math.trunc(fracToDouble(sArg));
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
return fromDouble(dayFraction / 86400);
},
},
};
@@ -0,0 +1,228 @@
/* =============================================================================
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorValue,
EvalContext,
EvalResult,
LazyArg,
RangeValue,
SheetFunction,
} from "./spreadsheet_types";
import {
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
} from "./spreadsheet_functions_math";
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
const out: Frac[] = [];
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (isErr(cell)) return cell;
if (isFrac(cell)) out.push(cell);
}
}
return out;
}
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
const n = frac(BigInt(values.length));
const mean = div(values.reduce(add, ZERO), n);
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
return { mean, sumSq };
}
function sampleVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 2) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
}
function populationVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 1) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
}
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
if (isErr(variance)) return variance;
return fromDouble(Math.sqrt(fracToDouble(variance)));
}
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
if (values.length === 0) return mkErr("#NUM!");
if (k < 0 || k > 1) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const pos = k * (sorted.length - 1);
const lowIdx = Math.floor(pos);
const highIdx = Math.ceil(pos);
if (lowIdx === highIdx) return sorted[lowIdx];
const weight = fromDouble(pos - lowIdx);
if (isErr(weight)) return weight;
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
}
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
const values = rangeNumbers(range);
if (isErr(values)) return values;
if (k < 1 || k > values.length) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
return sorted[largest ? sorted.length - k : k - 1];
}
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
const target = num(args[0]());
if (isErr(target)) return target;
const range = args[1]();
if (!isRange(range)) return mkErr("#VALUE!");
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
if (isErr(orderArg)) return orderArg;
const ascending = cmp(orderArg, ZERO) !== 0;
let rank = 1;
let found = false;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (!isFrac(cell)) continue;
if (cmp(cell, target) === 0) found = true;
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
}
}
return found ? frac(BigInt(rank)) : mkErr("#N/A");
}
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
MEDIAN: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[mid];
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
},
},
MODE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
let bestIdx = -1;
let bestCount = 1;
for (let i = 0; i < values.length; i++) {
let count = 0;
for (const v of values) if (cmp(values[i], v) === 0) count++;
if (count > bestCount) {
bestCount = count;
bestIdx = i;
}
}
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
},
},
STDEV: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.S": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.P": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(populationVariance(values));
},
},
VAR: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : sampleVariance(values);
},
},
RANK: { min: 2, max: 3, call: rankCall },
"RANK.EQ": { min: 2, max: 3, call: rankCall },
LARGE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
},
},
SMALL: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
},
},
PERCENTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, fracToDouble(kArg));
},
},
QUARTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const qArg = num(args[1]());
if (isErr(qArg)) return qArg;
const q = Math.trunc(fracToDouble(qArg));
if (q < 0 || q > 4) return mkErr("#NUM!");
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, q / 4);
},
},
COUNTBLANK: {
min: 1,
max: 1,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
}
return frac(BigInt(count));
},
},
};
+12 -9
View File
@@ -82,6 +82,17 @@ function collectRefs(
}
}
/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult {
try {
return evaluate(node, ctx);
} catch (e) {
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
return err("#DIV/0!", e.message);
return err("#VALUE!", e instanceof Error ? e.message : String(e));
}
}
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
export function createCalcEngine(book: Workbook): CalcEngine {
const parsed = new Map<string, ParseResult>();
@@ -285,15 +296,7 @@ export function createCalcEngine(book: Workbook): CalcEngine {
function run(text: string, ctx: EvalContext): Scalar {
const p = parse(text);
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
let v: EvalResult;
try {
v = evaluate(p.node, ctx);
} catch (e) {
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
return err("#DIV/0!", e.message);
return err("#VALUE!", e instanceof Error ? e.message : String(e));
}
const one = intersect(v, ctx);
const one = intersect(evalSafe(p.node, ctx), ctx);
return one === null ? ZERO : one;
}
+21 -9
View File
@@ -576,6 +576,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
return { x, y, w: box.w, h: box.h };
}
/** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어
* `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */
function overlayBoxOf(r: number, c: number): CellBox {
const box = boxOf(r, c);
const inFrozenCol = c < frozenCols;
const inFrozenRow = r < frozenRows;
return {
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
w: box.w,
h: box.h,
};
}
function renderSelection(): void {
let selLayer = overlay.querySelector<HTMLElement>(".aislo-grid__sel-layer");
if (!selLayer) {
@@ -596,8 +610,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const inFrozenCol = range.c0 < frozenCols;
const inFrozenRow = range.r0 < frozenRows;
const screen: CellBox = {
x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft,
y: inFrozenRow ? box.y : box.y - paneBR.scrollTop,
w: box.w,
h: box.h,
};
@@ -611,13 +625,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
node.style.borderWidth = i === 0 ? "2px" : "1px";
selLayer!.append(node);
}
const active = boxOf(sel.활성.r, sel.활성.c);
const inFrozenCol = sel.활성.c < frozenCols;
const inFrozenRow = sel.활성.r < frozenRows;
const active = overlayBoxOf(sel.활성.r, sel.활성.c);
const handle = document.createElement("div");
handle.className = "aislo-grid__fill-handle";
handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
handle.style.left = `${active.x + active.w - 4}px`;
handle.style.top = `${active.y + active.h - 4}px`;
selLayer!.append(handle);
}
@@ -643,8 +655,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const inFrozenRow = ref.범위.r0 < frozenRows;
const node = document.createElement("div");
node.className = "aislo-grid__ref-highlight";
node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
node.style.left = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`;
node.style.top = `${inFrozenRow ? merged.y : merged.y - paneBR.scrollTop}px`;
node.style.width = `${merged.w}px`;
node.style.height = `${merged.h}px`;
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
+9 -1
View File
@@ -41,6 +41,8 @@ export interface KeyHooks {
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
/** Ctrl+1 셀 서식 창 */
formatDialog?(): void;
/** Ctrl+Shift+L 필터 켜기 · 끄기 */
filter?(): void;
/** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */
@@ -68,12 +70,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
}
if (ctrl) return (ed.commitAll(), true);
ed.commit();
if (ed.editing()) return true; // 유효성에 막힘
next(e.shiftKey ? -1 : 1, 0);
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
if (ed.editing()) return true;
next(0, e.shiftKey ? -1 : 1);
return true;
case "Escape":
@@ -92,7 +96,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
}
if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김
ed.commit();
go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1]));
return true;
}
@@ -140,6 +144,10 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "1":
if (!hooks.formatDialog) return false;
hooks.formatDialog();
return true;
case "l":
if (!e.shiftKey || !hooks.filter) return false;
hooks.filter();
+13 -3
View File
@@ -106,7 +106,7 @@ export function attachMouse(
function fillDrag(): void {
const sheet = ctx.sheet();
const src = ctx.selection.범위[0];
const src = ctx.selection.범위[ctx.selection.범위.length - 1];
let result = { target: src, clear: null as CellRange | null };
drag(
(p) => {
@@ -186,6 +186,7 @@ export function attachMouse(
return;
}
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
}
ed.focus();
if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell";
@@ -205,9 +206,17 @@ export function attachMouse(
return drag((p) => ctx.select(selectCols(sheet, a, p.c, a)));
}
case "cell": {
const first = e.shiftKey
// Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림
const keep =
e.ctrlKey || e.metaKey
? ctx.selection.범위
: e.shiftKey
? ctx.selection.범위.slice(0, -1)
: [];
const one = e.shiftKey
? selectSpan(sheet, ctx.selection.기준, hit)
: selectCell(sheet, hit.r, hit.c);
const first = { ...one, 범위: [...keep, ...one.범위] };
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
@@ -215,7 +224,8 @@ export function attachMouse(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
const span = selectSpan(sheet, anchor, p);
ctx.select({ ...span, 범위: [...keep, ...span.범위] });
},
() => hooks.selected?.(),
);
+95 -1
View File
@@ -3,7 +3,8 @@
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로).
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
* ========================================================================== */
import {
@@ -364,6 +365,93 @@ function generalFormat(value: Frac): string {
return fracToString(value);
}
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
function toDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
function serialToDateUTC(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
type DatePart =
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
function detectDateFormat(s: string): DatePart[] | null {
const parts: DatePart[] = [];
let hasDateToken = false;
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '"') {
let j = i + 1;
let text = "";
while (j < s.length && s[j] !== '"') {
text += s[j];
j++;
}
parts.push({ k: "lit", text });
i = j + 1;
continue;
}
if (ch === "\\" && i + 1 < s.length) {
parts.push({ k: "lit", text: s[i + 1] });
i += 2;
continue;
}
const lower = ch.toLowerCase();
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
let j = i;
while (j < s.length && s[j].toLowerCase() === lower) j++;
const width = j - i;
if (lower === "a" && (width === 3 || width === 4)) {
parts.push({ k: "a", width });
hasDateToken = true;
} else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) {
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
hasDateToken = true;
} else {
parts.push({ k: "lit", text: s.slice(i, j) });
}
i = j;
continue;
}
parts.push({ k: "lit", text: ch });
i++;
}
return hasDateToken ? parts : null;
}
function renderDatePart(part: DatePart, date: Date): string {
if (part.k === "lit") return part.text;
if (part.k === "y") {
const y = date.getUTCFullYear();
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
}
if (part.k === "m") {
const m = date.getUTCMonth() + 1;
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
}
if (part.k === "d") {
const d = date.getUTCDate();
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
}
const wd = WEEKDAY_SHORT[date.getUTCDay()];
return part.width === 3 ? wd : `${wd}요일`;
}
function renderDateSection(parts: DatePart[], value: Frac): string {
const date = serialToDateUTC(toDouble(value));
return parts.map((p) => renderDatePart(p, date)).join("");
}
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
const isNeg = cmp(value, ZERO) < 0;
const isZero = cmp(value, ZERO) === 0;
@@ -385,6 +473,12 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
const sections = splitSections(code);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const datePlan = detectDateFormat(rest);
if (datePlan) {
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
}
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
+13 -4
View File
@@ -272,10 +272,19 @@ function prec(node: FormulaNode): number {
const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`);
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */
export function formulaToText(node: FormulaNode): string {
/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만.
* 계산 단계 보기용: `sub` 가 글을 주는 마디는 그 글(푼 값)로 갈음 · `mark` 마디는 \u0001 … \u0002 로 감쌈 */
export function formulaToText(
node: FormulaNode,
sub?: (node: FormulaNode) => string | undefined,
mark?: FormulaNode,
): string {
const own = sub?.(node);
if (own !== undefined) return own;
if (node === mark) return `\u0001${formulaToText(node, sub)}\u0002`;
const text = (child: FormulaNode) => formulaToText(child, sub, mark);
const wrap = (child: FormulaNode, need: boolean) =>
need ? `(${formulaToText(child)})` : formulaToText(child);
need && sub?.(child) === undefined ? `(${text(child)})` : text(child);
switch (node.type) {
case "number":
return node.text;
@@ -308,6 +317,6 @@ export function formulaToText(node: FormulaNode): string {
return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`;
}
case "call":
return `${node.name}(${node.args.map(formulaToText).join(",")})`;
return `${node.name}(${node.args.map(text).join(",")})`;
}
}
@@ -202,7 +202,7 @@ export function move(
if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c);
return selectCell(sheet, to.r, to.c);
}
const g = sel.범위[0] ?? span(sel.활성, sel.활성);
const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위
const anchor = sel.기준;
// 움직이는 끝 = 닻에서 먼 쪽
const far = {
@@ -218,7 +218,7 @@ export function move(
}
return {
시트: sheet.id,
범위: [expand(span(anchor, to), list)],
범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)],
활성: { ...sel.활성 },
기준: { ...anchor },
};
@@ -0,0 +1,113 @@
/* =============================================================================
* spreadsheet_sheet_extra.ts (3단계 · 주인 sub_laptop_4)
* 시트 숨기기 · 보이기 · 시트 탭 색. 저장 자리 `Sheet.숨김` · `Sheet.탭색`(0 계약에 방금 더함).
* 이 파일은 관리 창(전체 시트 목록 + 숨김 · 색 조작)만 그림 — 실제 탭 줄(`spreadsheet_tabs.ts`,
* E 파일)을 안 건드림.
*
* 잇는 법(E `spreadsheet_tabs.ts` · sub7 — 지금은 하나도 안 이어짐):
* 1) `mountTabs` 의 `render()` 가 `ctx.book.시트` 를 돌 때 `숨김` 인 시트는 탭을 안 그림.
* 2) 탭 엘리먼트에 `sheet.탭색` 이 있으면 밑줄 · 배경을 그 색으로(엑셀처럼).
* 3) 숨은 시트를 다시 보이려면 탭 우클릭 메뉴에 [숨긴 시트 보이기] 항목을 두고 이 파일의
* `createSheetExtraPanel(ctx)` 를 열거나, `showSheet(ctx, id)` 를 바로 부름.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { Sheet } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void {
ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } });
}
/** 시트를 숨김 — 마지막 보이는 시트는 못 숨김(까닭 글 돌려줌 · 되면 null). 활성 시트를 숨기면
* 다른 보이는 시트로 활성을 옮김. */
export function hideSheet(ctx: SpreadsheetContext, id: string): string | null {
const others = ctx.book.시트.filter((s) => s.id !== id && !s.숨김);
if (!others.length) return "마지막 보이는 시트는 못 숨김";
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || sheet.숨김) return null;
applySheet(ctx, id, { ...sheet, 숨김: true });
if (ctx.book.활성 === id) ctx.showSheet(others[0].id);
return null;
}
/** 숨긴 시트를 다시 보임. */
export function unhideSheet(ctx: SpreadsheetContext, id: string): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet || !sheet.숨김) return;
applySheet(ctx, id, { ...sheet, 숨김: false });
}
/** 탭 색 — `color` 가 null 이면 색을 지움(기본 탭 색으로). */
export function setSheetTabColor(ctx: SpreadsheetContext, id: string, color: string | null): void {
const sheet = ctx.book.시트.find((s) => s.id === id);
if (!sheet) return;
const next: Sheet = { ...sheet };
if (color) next.탭색 = color;
else delete next.탭색;
applySheet(ctx, id, next);
}
/** 시트 관리 창 — 전체 시트 목록 + 숨김 토글 · 탭 색 고르기. */
export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-sheetextra" });
const list = el("ul", { className: "ss-sheetextra__list" });
let error: HTMLElement | null = null;
const showError = (msg: string | null): void => {
error?.remove();
error = null;
if (!msg) return;
error = el("p", { text: msg });
error.style.color = "var(--color-danger, red)";
root.append(error);
};
function render(): void {
list.replaceChildren(
...ctx.book.시트.map((sheet) => {
const li = el("li", { className: "ss-sheetextra__row" });
const label = el("span", { text: sheet.이름 + (sheet.숨김 ? "(숨김)" : "") });
const colorInput = el("input", {
attrs: { type: "color", value: sheet.탭색 ?? "#ffffff" },
}) as HTMLInputElement;
colorInput.addEventListener("change", () =>
setSheetTabColor(ctx, sheet.id, colorInput.value),
);
const colorClear = createButton({ label: "색 지움", variant: "ghost" });
colorClear.addEventListener("click", () => {
setSheetTabColor(ctx, sheet.id, null);
render();
});
const toggle = createButton({
label: sheet.숨김 ? "보이기" : "숨기기",
variant: "ghost",
});
toggle.addEventListener("click", () => {
if (sheet.숨김) {
unhideSheet(ctx, sheet.id);
showError(null);
} else {
showError(hideSheet(ctx, sheet.id));
}
render();
});
li.append(label, colorInput, colorClear, toggle);
return li;
}),
);
}
root.append(el("h4", { text: "시트 관리" }), list);
render();
return {
root,
refresh: render,
destroy(): void {
root.remove();
},
};
}
+28 -4
View File
@@ -1,11 +1,13 @@
/* =============================================================================
* spreadsheet_stage3.css (주인 sub_laptop_4)
* 조건부 서식 · 데이터 유효성 · 이름 정의 관리 창 — 화면 스타일 한 벌. 색 · 간격은 테마 토큰만.
* 조건부 서식 · 데이터 유효성 · 이름 정의 · 상태 표시줄 · 시트 관리 창 — 화면 스타일 한 벌.
* 색 · 간격은 테마 토큰만.
* ========================================================================== */
.ss-condfmt,
.ss-validation,
.ss-names {
.ss-names,
.ss-sheetextra {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
@@ -18,7 +20,8 @@
.ss-condfmt__list,
.ss-validation__list,
.ss-names__list {
.ss-names__list,
.ss-sheetextra__list {
list-style: none;
margin: 0;
padding: 0;
@@ -29,7 +32,8 @@
.ss-condfmt__row,
.ss-validation__row,
.ss-names__row {
.ss-names__row,
.ss-sheetextra__row {
display: flex;
align-items: center;
gap: var(--spacing-4);
@@ -85,3 +89,23 @@
.ss-validation__dropdown-item:hover {
background: var(--color-mist-violet);
}
.ss-statusbar {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: 2px var(--spacing-8);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
}
.ss-statusbar__stats {
display: flex;
gap: var(--spacing-8);
flex: 1;
}
.ss-statusbar__zoom {
color: var(--color-border);
}
@@ -0,0 +1,122 @@
/* =============================================================================
* spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4)
* 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대.
* 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐).
* 확대 비율 자리는 칸만 둠 — 실제 격자 확대와는 아직 안 이어짐(아래 「잇는 법」 2).
*
* 잇는 법(D · C · sub7 — 지금은 하나도 안 이어짐):
* 1) `spreadsheet.ts` 가 `createStatusBar(ctx)` 를 한 번 부르고 `root` 를 화면 맨 아래 붙임.
* `ctx.select()` 뒤(그리고 `dispatch` 로 값이 바뀐 뒤) `refresh()` 를 부름 — 스스로 구독 안 함.
* 2) 확대 비율을 실제로 쓰려면 `GridHandle` 에 `zoom()` · `setZoom()` 을 계약(0)에 더하고(격자 C 몫),
* 이 파일의 확대 자리를 그 값으로 채우고 바꾸면 `setZoom` 을 부르게 이음.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
import type { CellRange, Scalar } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함.
export type StatKey = "합계" | "평균" | "개수" | "숫자개수" | "최소" | "최대";
export const STAT_KEYS: StatKey[] = ["합계", "평균", "개수", "숫자개수", "최소", "최대"];
export interface SelectionStats {
합계: number | null;
평균: number | null;
개수: number;
숫자개수: number;
최소: number | null;
최대: number | null;
}
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
const sheet = ctx.selection.시트;
const nums: number[] = [];
let 개수 = 0;
const seen = new Set<number>();
const visit = (range: CellRange): void => {
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= range.c1; c++) {
const key = r * 1_000_000 + c;
if (seen.has(key)) continue;
seen.add(key);
const value: Scalar = ctx.engine.value(sheet, r, c);
if (value === null) continue;
개수++;
if (isFrac(value)) nums.push(fracToDouble(value));
}
}
};
for (const range of ctx.selection.범위) visit(range);
return {
합계: nums.length ? nums.reduce((a, b) => a + b, 0) : null,
평균: nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null,
개수,
숫자개수: nums.length,
최소: nums.length ? Math.min(...nums) : null,
최대: nums.length ? Math.max(...nums) : null,
};
}
function statText(key: StatKey, stats: SelectionStats): string {
const value = stats[key];
if (key === "개수" || key === "숫자개수") return `${key}: ${value}`;
return `${key}: ${value === null ? "—" : formatNumber(value)}`;
}
function formatNumber(n: number): string {
return Number.isInteger(n)
? String(n)
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
}
/** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */
export function createStatusBar(ctx: SpreadsheetContext): PartHandle {
const root = el("div", { className: "ss-statusbar" });
const stats = el("div", { className: "ss-statusbar__stats" });
const zoom = el("span", { className: "ss-statusbar__zoom", text: "확대 100%" });
const menu = createMapContextMenu("ss-statusbar");
root.append(stats, zoom, menu.element);
let visible = new Set<StatKey>(STAT_KEYS);
function render(): void {
const s = computeSelectionStats(ctx);
stats.replaceChildren(
...STAT_KEYS.filter((k) => visible.has(k)).map((k) =>
el("span", { className: "ss-statusbar__item", text: statText(k, s) }),
),
);
}
root.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
const box = root.getBoundingClientRect();
const items: MapContextMenuItem[] = STAT_KEYS.map((k) => [
`${visible.has(k) ? "✓ " : ""}${k}`,
() => {
if (visible.has(k)) visible.delete(k);
else visible.add(k);
render();
},
]);
menu.open(ev.clientX - box.left, ev.clientY - box.top, items);
});
render();
return {
root,
refresh: render,
destroy(): void {
menu.close();
root.remove();
},
};
}
+285
View File
@@ -0,0 +1,285 @@
/* =============================================================================
* spreadsheet_trace.ts (주인 A)
* 선행 · 종속 칸 추적 화살표(엑셀 [수식 분석]) — 엔진 의존 그래프(`engine.precedents`)로 한 단계씩 넓힘.
* 엑셀 규칙: 누를 때마다 한 단계 더(이미 편 칸 너머만) · 범위는 파란 상자 + 화살 · 오류 칸에서 나온 화살은 빨강 ·
* 다른 시트 칸은 점선 화살 + 시트 표 아이콘 · 문서가 바뀌면 화살을 모두 지움.
*
* 잇는 법(D · sub7):
* · 부품 붙이기: `const trace = attachTrace(ctx)` — SVG 겹판을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `trace.refresh()` · 스크롤은 스스로 따라감 · 시트를 바꾸면 그 시트 화살만 그림.
* · 도구 모음 [수식 분석]: 「선행 참조 추적」 → `trace.precedents()` · 「종속 참조 추적」 → `trace.dependents()` ·
* 「화살표 지우기」 → `trace.clear()`(종류만 지우려면 `clear("선행")`). 더 펼 칸이 없으면 false(토스트 몫).
* ========================================================================== */
import { cellId, inRange, parseA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import type { CalcEngine, CellRange, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
export type TraceKind = "선행" | "종속";
/** 화살 하나 — 늘 가리켜진 쪽(from) → 식 칸(to) */
export interface TraceArrow {
종류: TraceKind;
from: { 시트: string; 범위: CellRange };
to: SheetCellAddress;
/** 시작 칸 · 범위에 오류 값이 있음 → 빨강 */
오류: boolean;
}
export interface TraceState {
arrows: TraceArrow[];
/** 이미 편 칸(`종류:시트!id`) */
opened: Set<string>;
}
const key = (kind: TraceKind, sheet: string, r: number, c: number) =>
`${kind}:${sheet}!${cellId(r, c)}`;
const one = (r: number, c: number): CellRange => ({ r0: r, c0: c, r1: r, c1: c });
/** 시트의 식 칸 목록(범위 안만) */
function formulaCells(book: Workbook, sheet: string, rg?: CellRange): SheetCellAddress[] {
const s = book.시트.find((x) => x.id === sheet);
const out: SheetCellAddress[] = [];
for (const [a1, cell] of Object.entries(s?.칸 ?? {})) {
const at = cell.식 !== undefined ? parseA1(a1) : null;
if (at && (!rg || inRange(rg, at.r, at.c))) out.push({ 시트: sheet, ...at });
}
return out;
}
function hasError(book: Workbook, engine: CalcEngine, sheet: string, rg: CellRange): boolean {
if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return isError(engine.value(sheet, rg.r0, rg.c0));
const s = book.시트.find((x) => x.id === sheet);
for (const a1 of Object.keys(s?.칸 ?? {})) {
const at = parseA1(a1);
if (at && inRange(rg, at.r, at.c) && isError(engine.value(sheet, at.r, at.c))) return true;
}
return false;
}
/** 칸을 가리키는 식 칸들(모든 시트) */
export function dependentsOf(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const s of book.시트)
for (const f of formulaCells(book, s.id))
if (
engine.precedents(f.시트, f.r, f.c).some((d) => d.시트 === sheet && inRange(d.범위, r, c))
)
out.push(f);
return out;
}
/** 한 단계 넓힘 — 처음이면 그 칸 · 아니면 그 칸에서 이어진 화살 끝 중 아직 안 편 칸. 더한 화살이 있으면 true */
export function traceStep(
book: Workbook,
engine: CalcEngine,
state: TraceState,
kind: TraceKind,
at: SheetCellAddress,
): boolean {
// 이어진 칸을 따라가며 안 편 칸(앞날개)을 모음
const frontier: SheetCellAddress[] = [];
const seen = new Set<string>();
const walk = [at];
while (walk.length) {
const p = walk.pop()!;
const k = key(kind, p.시트, p.r, p.c);
if (seen.has(k)) continue;
seen.add(k);
if (!state.opened.has(k)) {
frontier.push(p);
continue;
}
for (const a of state.arrows) {
if (a.종류 !== kind) continue;
if (kind === "선행" && a.to.시트 === p.시트 && a.to.r === p.r && a.to.c === p.c)
walk.push(...formulaCells(book, a.from.시트, a.from.범위));
if (kind === "종속" && a.from.시트 === p.시트 && inRange(a.from.범위, p.r, p.c))
walk.push(a.to);
}
}
let added = false;
for (const p of frontier) {
state.opened.add(key(kind, p.시트, p.r, p.c));
const links =
kind === "선행"
? engine.precedents(p.시트, p.r, p.c).map((d) => ({ from: d, to: p }))
: dependentsOf(book, engine, p.시트, p.r, p.c).map((to) => ({
from: { 시트: p.시트, 범위: one(p.r, p.c) },
to,
}));
for (const { from, to } of links) {
state.arrows.push({
종류: kind,
from,
to,
오류: hasError(book, engine, from.시트, from.범위),
});
added = true;
}
}
return added;
}
// ── 화면 겹판 ────────────────────────────────────────────────────────────────
const SVG = "http://www.w3.org/2000/svg";
const svg = <K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [a, v] of Object.entries(attrs)) n.setAttribute(a, String(v));
return n;
};
export interface TraceHandle extends PartHandle {
/** 활성 칸 선행 추적 한 단계 — 더한 화살이 없으면 false */
precedents(): boolean;
dependents(): boolean;
clear(kind?: TraceKind): void;
/** 지금 화살(시험 · 상태 표시용) */
arrows(): readonly TraceArrow[];
}
export function attachTrace(ctx: SpreadsheetContext): TraceHandle {
const layer = svg("svg", { class: "ss-trace" });
const defs = svg("defs", {});
for (const kind of ["plain", "error", "sheet"]) {
const m = svg("marker", {
id: `ss-trace-head-${kind}`,
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 7,
markerHeight: 7,
orient: "auto-start-reverse",
});
m.append(svg("path", { d: "M0,0 L10,5 L0,10 z", class: `ss-trace__head is-${kind}` }));
defs.append(m);
}
let state: TraceState = { arrows: [], opened: new Set() };
let book = ctx.book;
/** 칸 상자 → 겹판 자리(filter 와 같은 맞춤) */
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 draw(): void {
if (ctx.book !== book) {
state = { arrows: [], opened: new Set() };
book = ctx.book;
}
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const here = ctx.sheet().id;
const parts: SVGElement[] = [defs];
const boxes = new Set<string>();
for (const a of state.arrows) {
const fromHere = a.from.시트 === here;
const toHere = a.to.시트 === here;
if (!fromHere && !toHere) continue;
const tone = a.오류 ? "error" : fromHere && toHere ? "plain" : "sheet";
const t = toHere ? boxIn(a.to.r, a.to.c) : null;
const rg = a.from.범위;
const f = fromHere ? boxIn(rg.r0, rg.c0) : null;
const end = t
? { x: t.x + t.w / 2, y: t.y + t.h / 2 }
: { x: f!.x + f!.w + 60, y: f!.y - 24 };
const start = f
? { x: f.x + Math.min(f.w, 24) / 2, y: f.y + f.h / 2 }
: { x: t!.x - 60, y: t!.y - 24 };
if (
f &&
(rg.r0 !== rg.r1 || rg.c0 !== rg.c1) &&
!boxes.has(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`)
) {
boxes.add(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`);
const b = boxIn(rg.r1, rg.c1);
parts.push(
svg("rect", {
class: `ss-trace__box is-${a.오류 ? "error" : "plain"}`,
x: f.x + 1,
y: f.y + 1,
width: Math.max(0, b.x + b.w - f.x - 2),
height: Math.max(0, b.y + b.h - f.y - 2),
}),
);
}
if (f)
parts.push(
svg("circle", { class: `ss-trace__dot is-${tone}`, cx: start.x, cy: start.y, r: 3 }),
);
else
parts.push(
svg("rect", {
class: "ss-trace__sheet",
x: start.x - 9,
y: start.y - 7,
width: 18,
height: 14,
}),
);
parts.push(
svg("line", {
class: `ss-trace__line is-${tone}`,
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
"marker-end": `url(#ss-trace-head-${tone})`,
"data-from": `${a.from.시트}!${rg.r0},${rg.c0}`,
"data-to": `${a.to.시트}!${a.to.r},${a.to.c}`,
}),
);
if (!t)
parts.push(
svg("rect", { class: "ss-trace__sheet", x: end.x, y: end.y - 7, width: 18, height: 14 }),
);
}
layer.replaceChildren(...parts);
}
const step = (kind: TraceKind) => {
if (ctx.book !== book) draw();
const { r, c } = ctx.selection.활성;
const added = traceStep(ctx.book, ctx.engine, state, kind, { 시트: ctx.sheet().id, r, c });
draw();
return added;
};
const onScroll = () => draw();
ctx.grid.root.addEventListener("scroll", onScroll, true);
draw();
return {
root: layer as unknown as HTMLElement,
refresh: draw,
precedents: () => step("선행"),
dependents: () => step("종속"),
clear(kind) {
if (!kind) state = { arrows: [], opened: new Set() };
else {
state.arrows = state.arrows.filter((a) => a.종류 !== kind);
for (const k of [...state.opened]) if (k.startsWith(`${kind}:`)) state.opened.delete(k);
}
draw();
},
arrows: () => state.arrows,
destroy() {
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}
+12 -1
View File
@@ -31,6 +31,10 @@
* A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250
* A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300
* A spreadsheet_sortfilter.css ~150
* A spreadsheet_trace.ts 선행 · 종속 추적 화살표(한 단계씩 · SVG 겹판) ~270
* A spreadsheet_eval_steps.ts 수식 계산 단계 보기 창 ~230
* A spreadsheet_errcheck.ts 오류 검사 까닭 · ⚠ 단추 · 풍선(세모는 C 가 그림) ~200
* A spreadsheet_analysis.css 위 셋 화면 스타일 ~200
* A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80
* B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120
* B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400
@@ -55,7 +59,10 @@
* 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260
* 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230
* 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220
* 3 spreadsheet_stage3.css 위 셋 화면 스타일 한 벌 ~90
* 3 spreadsheet_statusbar.ts 아래 상태 표시줄 — 합계 · 평균 · 개수 등(주인 sub_laptop_4 · 안 이어짐) ~150
* 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90
* 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150
* 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140
* F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40
* F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60
* F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 ·
@@ -172,6 +179,10 @@ export interface Sheet {
/** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는
* A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */
이름정의?: NamedRange[];
/** 시트 숨김(`spreadsheet_sheet_extra.ts`) — 탭은 `spreadsheet_tabs.ts`(E)가 이 값으로 걸러야 함(안 이어짐). */
숨김?: boolean;
/** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */
탭색?: string;
}
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
@@ -0,0 +1,27 @@
<!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-prec">선행 참조 추적</button>
<button id="btn-dep">종속 참조 추적</button>
<button id="btn-clear">화살표 지우기</button>
<button id="btn-steps">수식 계산</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_analysis/harness.js"></script>
</body>
</html>
@@ -0,0 +1,72 @@
/* 수식 분석 화면 시험 틀 — 진짜 부품(createSpreadsheet)에 추적 화살 · 수식 계산 창 · 오류 ⚠ 를 붙임.
* sub7 잇기 전이라 `inspect` 로 문맥(ctx)을 꺼내고, grid.render 뒤 refresh 를 부름.
* 번들: node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/harness.js (git 밖) */
import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
import { attachTrace } from "../../../A00_Common/spreadsheet/spreadsheet_trace";
import { openEvalSteps } from "../../../A00_Common/spreadsheet/spreadsheet_eval_steps";
import { attachErrorTip } from "../../../A00_Common/spreadsheet/spreadsheet_errcheck";
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>;
const book: Workbook = {
종류: "통합문서",
판: 1,
이름: "harness",
서식: [{}],
시트: [
{
id: "s1",
이름: "분석",
칸: {
A1: { 값: "단가" },
B1: { 값: "수량" },
C1: { 값: "금액" },
A2: { 값: 1200 },
B2: { 값: 3 },
C2: { 식: "A2*B2" },
A3: { 값: 800 },
B3: { 값: "5" },
C3: { 식: "A3*B3" },
A4: { 값: 50 },
B4: { 값: 0 },
C4: { 식: "A4/B4" },
C6: { 식: "SUM(C2:C3)+IF(B2>2,C2*10%,0)" },
D6: { 식: "C6*2" },
E2: { 식: "E3+1" },
E3: { 식: "E2+1" },
},
},
{ id: "s2", 이름: "요약", 칸: { A1: { 식: "분석!D6" } } },
],
활성: "s1",
};
const host = document.getElementById("host")!;
w.ss = createSpreadsheet(host, book, { inspect: true });
const c = w.__aisloSheet as SpreadsheetContext;
const trace = attachTrace(c);
const tip = attachErrorTip(c, trace);
const render = c.grid.render.bind(c.grid);
c.grid.render = () => {
render();
trace.refresh();
tip.refresh();
};
const renderSel = c.grid.renderSelection.bind(c.grid);
c.grid.renderSelection = () => {
renderSel();
tip.refresh();
};
w.ssCtx = c;
w.ssTrace = trace;
w.ssTip = tip;
w.ssSteps = () => openEvalSteps(c);
for (const [id, run] of [
["btn-prec", () => trace.precedents()],
["btn-dep", () => trace.dependents()],
["btn-clear", () => trace.clear()],
["btn-steps", () => openEvalSteps(c)],
] as const)
document.getElementById(id)!.addEventListener("click", run);
@@ -0,0 +1,29 @@
/* 수식 분석 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는 <style> 로 넣음 · @ui 별칭.
* node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/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_analysis.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_analysis", "harness.js"), format: "iife" },
}).then(
() => console.log("ok"),
(e) => {
console.error(e.message);
process.exit(1);
},
);
@@ -1,7 +1,7 @@
/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음.
* sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
* sub7 잇기 전이라 `inspect` 로 문맥(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 { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
import {
openSortDialog,
sortSelection,
@@ -11,12 +11,6 @@ import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreads
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],
@@ -54,9 +48,10 @@ const book: Workbook = {
const host = document.getElementById("host")!;
w.ss = createSpreadsheet(host, book, {
inspect: true,
onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1),
});
const c = ctx as unknown as SpreadsheetContext;
const c = w.__aisloSheet as SpreadsheetContext;
const filter = attachFilter(c);
const render = c.grid.render.bind(c.grid);
c.grid.render = () => {
@@ -0,0 +1,167 @@
/* 수식 분석 엔진 시험 헬퍼 — 추적 화살표 단계 · 수식 계산 걸음 · 오류 검사 까닭을 묶음마다 돌려 틀린 것 목록을 JSON 으로.
* 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀.
*/
const { A, G, load } = require("./helper_spreadsheet_engine.cjs");
const TR = load("spreadsheet_trace");
const ST = load("spreadsheet_eval_steps");
const EC = load("spreadsheet_errcheck");
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 cellsOf = (cells) => {
const 칸 = {};
for (const [k, v] of Object.entries(cells))
칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v };
return 칸;
};
function book(cells, sheet2) {
const 시트 = [{ id: "s1", 이름: "본", 칸: cellsOf(cells) }];
if (sheet2) 시트.push({ id: "s2", 이름: "둘", 칸: cellsOf(sheet2) });
return { 종류: "통합문서", 판: 1, 이름: "t", 서식: [{}], 시트 };
}
const at = (a1, 시트 = "s1") => ({ 시트, ...A.parseA1(a1) });
const name = (a) => `${a.시트}!${A.toA1(a.r, a.c)}`;
const arrowText = (a) =>
`${a.from.시트}!${A.rangeToA1(a.from.범위)}>${name(a.to)}${a.오류 ? "!" : ""}`;
// ── 1. 선행 · 종속 추적 ──────────────────────────────────────────────────────
section("trace", () => {
const b = book(
{ A1: 1, A2: 2, B1: "=A1+A2", C1: "=B1*2", D1: "=SUM(A1:A2)", A3: "=1/0", B3: "=A3+1" },
{ E1: "=본!C1" },
);
const e = G.createCalcEngine(b);
const st = { arrows: [], opened: new Set() };
// 선행: C1 → 한 단계씩
eq("선행 1", TR.traceStep(b, e, st, "선행", at("C1")), true);
eq("선행 1 화살", st.arrows.map(arrowText), ["s1!B1>s1!C1"]);
eq("선행 2", TR.traceStep(b, e, st, "선행", at("C1")), true);
eq("선행 2 화살", st.arrows.map(arrowText).slice(1), ["s1!A1>s1!B1", "s1!A2>s1!B1"]);
eq("선행 3 더 없음", TR.traceStep(b, e, st, "선행", at("C1")), false);
// 종속: A1 → B1 · D1(범위로) → C1 → 둘!E1(다른 시트)
const sd = { arrows: [], opened: new Set() };
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 1", sd.arrows.map((a) => name(a.to)).sort(), ["s1!B1", "s1!D1"]);
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 2", sd.arrows.map((a) => name(a.to)).slice(2), ["s1!C1"]);
TR.traceStep(b, e, sd, "종속", at("A1"));
eq("종속 3 다른 시트", sd.arrows.map(arrowText).slice(3), ["s1!C1>s2!E1"]);
eq("종속 4 더 없음", TR.traceStep(b, e, sd, "종속", at("A1")), false);
// 범위 선행 · 오류 화살
const sr = { arrows: [], opened: new Set() };
TR.traceStep(b, e, sr, "선행", at("D1"));
eq("범위 화살", sr.arrows.map(arrowText), ["s1!A1:A2>s1!D1"]);
const se = { arrows: [], opened: new Set() };
TR.traceStep(b, e, se, "선행", at("B3"));
eq("오류 칸 화살 빨강", se.arrows.map(arrowText), ["s1!A3>s1!B3!"]);
eq("값 칸은 선행 없음", TR.traceStep(b, e, { arrows: [], opened: new Set() }, "선행", at("A1")), false);
eq(
"dependentsOf",
TR.dependentsOf(b, e, "s1", 0, 1).map(name),
["s1!C1"],
);
});
// ── 2. 수식 계산 걸음 ────────────────────────────────────────────────────────
const stepsOf = (cells, a1) => {
const b = book(cells);
const e = G.createCalcEngine(b);
const p = A.parseA1(a1);
return ST.evalSteps(b, e, "s1", p.r, p.c).map((s) =>
s.밑줄 ? `${s.글.slice(0, s.밑줄[0])}[${s.글.slice(s.밑줄[0], s.밑줄[1])}]${s.글.slice(s.밑줄[1])}` : s.글,
);
};
section("eval_steps", () => {
eq("차례 · 빈 칸 0 · 범위는 함수가", stepsOf({ A1: 3, A3: 0, B1: "=A1*2+SUM(A1:A3)-A2" }, "B1"), [
"[A1]*2+SUM(A1:A3)-A2",
"[3*2]+SUM(A1:A3)-A2",
"6+[SUM(A1:A3)]-A2",
"[6+3]-A2",
"9-[A2]",
"[9-0]",
"9",
]);
eq("괄호 안 밑줄", stepsOf({ A1: 3, B1: "=(A1+1)*2" }, "B1"), [
"([A1]+1)*2",
"([3+1])*2",
"[4*2]",
"8",
]);
eq("IF 는 첫 인자만 먼저 · 안 고른 쪽 오류 무시", stepsOf({ A1: 3, B1: '=IF(A1>2,"큼",1/0)' }, "B1"), [
'IF([A1]>2,"큼",1/0)',
'IF([3>2],"큼",1/0)',
'[IF(TRUE,"큼",1/0)]',
'"큼"',
]);
eq("글 따옴표", stepsOf({ A1: 'a"b', B1: '=A1&"x"' }, "B1"), [
'[A1]&"x"',
'["a""b"&"x"]',
'"a""bx"',
]);
eq("음수 글자는 그대로", stepsOf({ B1: "=-5+1" }, "B1"), ["[-5+1]", "-4"]);
eq("오류 전파", stepsOf({ A1: 0, B1: "=1/A1+1" }, "B1"), [
"1/[A1]+1",
"[1/0]+1",
"[#DIV/0!+1]",
"#DIV/0!",
]);
eq("값 칸은 null", ST.evalSteps(book({ A1: 1 }), G.createCalcEngine(book({ A1: 1 })), "s1", 0, 0), null);
eq("상수 식", stepsOf({ B1: "=5" }, "B1"), ["5"]);
});
// ── 3. 오류 검사 까닭 ────────────────────────────────────────────────────────
section("errcheck", () => {
const b = book({
A1: "1,234",
A2: "abc",
A3: "12a",
A4: 5,
A5: "=1/0",
A6: "=A7",
A7: "=A6",
A8: " 1e3 ",
A9: "=A5+1",
A10: "=A4*2",
});
const e = G.createCalcEngine(b);
const w = (a1) => {
const p = A.parseA1(a1);
const x = EC.cellWarning(b, e, "s1", p.r, p.c);
return x && x.종류;
};
eq(
"종류",
["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "B1"].map(w),
["글숫자", null, null, null, "오류", "순환", "순환", "글숫자", "오류", null, null],
);
const why = EC.cellWarning(b, e, "s1", 4, 0).까닭;
eq("#DIV/0! 까닭", why.startsWith("#DIV/0! 0 으로 나눔"), true);
eq("순환 까닭", EC.cellWarning(b, e, "s1", 5, 0).까닭.includes("A6 → A7"), true);
eq("numberLike", ["1,234", " 1e3 ", "1,23", ".5", "-", "+7"].map(EC.numberLike), [
1234,
1000,
null,
0.5,
null,
7,
]);
});
process.stdout.write(JSON.stringify(results));
@@ -21,6 +21,7 @@ function loadTs(file) {
cache.set(full, mod);
const req = (r) => {
if (r === "./spreadsheet_functions") return fakeFunctions;
if (r.endsWith(".css")) return {};
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`));
return require(r);
@@ -294,6 +294,117 @@ errEq("NA", call("NA"), "#N/A");
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 통계(3차) ────────────────────────────────────────────────────────────
fracEq("MEDIAN 홀수", call("MEDIAN", n(1), n(2), n(3), n(4), n(5)), "3");
fracEq("MEDIAN 짝수", call("MEDIAN", n(1), n(2), n(3), n(4)), "2.5");
fracEq("MODE 최빈값", call("MODE", n(1), n(2), n(2), n(3), n(3), n(3)), "3");
errEq("MODE 겹침 없음", call("MODE", n(1), n(2), n(3)), "#N/A");
fracEq("VAR 표본분산", call("VAR", n(1), n(2), n(3)), "1");
fracEq("STDEV 표본표준편차", call("STDEV", n(1), n(2), n(3)), "1");
fracEq("STDEV.S", call("STDEV.S", n(1), n(2), n(3)), "1");
fracEq("STDEV.P 모표준편차", call("STDEV.P", n(2), n(4), n(4), n(4), n(5), n(5), n(7), n(9)), "2");
{
const RANKS = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
fracEq("RANK 내림차순(기본)", call("RANK", n(30), RANKS), "2");
fracEq("RANK 오름차순", call("RANK", n(30), RANKS, n(1)), "3");
fracEq("RANK.EQ", call("RANK.EQ", n(30), RANKS), "2");
errEq("RANK 없는 값", call("RANK", n(99), RANKS), "#N/A");
fracEq("LARGE 1위", call("LARGE", RANKS, n(1)), "40");
fracEq("LARGE 2위", call("LARGE", RANKS, n(2)), "30");
fracEq("SMALL 1위", call("SMALL", RANKS, n(1)), "10");
errEq("LARGE 범위밖", call("LARGE", RANKS, n(9)), "#NUM!");
}
{
const NUMS4 = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("PERCENTILE 중간", call("PERCENTILE", NUMS4, n("0.5")), "2.5");
fracEq("PERCENTILE 0", call("PERCENTILE", NUMS4, n(0)), "1");
fracEq("PERCENTILE 1", call("PERCENTILE", NUMS4, n(1)), "4");
fracEq("QUARTILE 중앙값(2)", call("QUARTILE", NUMS4, n(2)), "2.5");
fracEq("QUARTILE 최솟값(0)", call("QUARTILE", NUMS4, n(0)), "1");
errEq("QUARTILE 범위밖", call("QUARTILE", NUMS4, n(9)), "#NUM!");
}
fracEq("COUNTBLANK", call("COUNTBLANK", range(1, 4, [[frac(1n), null, "x", null]])), "2");
// ── 날짜 · 시간(3차) ──────────────────────────────────────────────────────
function dateSerial(y: number, m: number, day: number): EvalResult {
return call("DATE", n(y), n(m), n(day));
}
{
const serial = dateSerial(2026, 3, 15);
const wantDow = new Date(Date.UTC(2026, 2, 15)).getUTCDay(); // 0=일..6=토
fracEq("WEEKDAY 기본(1=일)", call("WEEKDAY", lit(serial)), String(wantDow + 1));
fracEq(
"WEEKDAY 유형2(1=월)",
call("WEEKDAY", lit(serial), n(2)),
String(((wantDow + 6) % 7) + 1),
);
}
{
// 월말 넘침 — 1/31 + 1개월은 다음 달로 안 넘고 그 달 말일(2/28)로.
const edated = call("EDATE", lit(dateSerial(2026, 1, 31)), n(1));
fracEq("EDATE 월말 눌러담음(월)", call("MONTH", lit(edated)), "2");
fracEq("EDATE 월말 눌러담음(일)", call("DAY", lit(edated)), "28");
const normal = call("EDATE", lit(dateSerial(2026, 1, 15)), n(1));
fracEq("EDATE 평범한 경우(일)", call("DAY", lit(normal)), "15");
}
{
const eom = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(0));
fracEq("EOMONTH 이번달 말일", call("DAY", lit(eom)), "28");
const eomNext = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(1));
fracEq("EOMONTH 다음달 말일", call("DAY", lit(eomNext)), "31");
}
fracEq(
"DATEDIF Y",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"3",
);
fracEq(
"DATEDIF Y(생일 전)",
call("DATEDIF", lit(dateSerial(2020, 6, 15)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"2",
);
fracEq(
"DATEDIF M",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("M")),
"36",
);
fracEq(
"DATEDIF D",
call("DATEDIF", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 31)), txt("D")),
"30",
);
errEq(
"DATEDIF 끝이 이름(#NUM!)",
call("DATEDIF", lit(dateSerial(2024, 1, 10)), lit(dateSerial(2024, 1, 1)), txt("D")),
"#NUM!",
);
{
const wantMon = new Date(Date.UTC(2024, 0, 1)).getUTCDay() === 1;
ok("2024-01-01 은 월요일(전제)", wantMon);
const nw = call("NETWORKDAYS", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 7)));
fracEq("NETWORKDAYS 한 주", nw, "5");
const nwHoliday = call(
"NETWORKDAYS",
lit(dateSerial(2024, 1, 1)),
lit(dateSerial(2024, 1, 7)),
lit(dateSerial(2024, 1, 3)),
);
fracEq("NETWORKDAYS 휴일 뺌", nwHoliday, "4");
}
fracEq("HOUR", call("HOUR", lit(call("TIME", n(13), n(45), n(30)))), "13");
fracEq("MINUTE", call("MINUTE", lit(call("TIME", n(13), n(45), n(30)))), "45");
{
const t = call("TIME", n(6), n(0), n(0));
ok(
"TIME(6,0,0) = 0.25",
isFrac(t) && fracToString(t) === "0.25",
isFrac(t) ? fracToString(t) : String(t),
);
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -346,6 +457,21 @@ ok(
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
// 날짜 형식(3차)
{
const serial = dateSerial(2026, 3, 5) as Scalar;
ok("yyyy-mm-dd", fmt(serial, "yyyy-mm-dd") === "2026-03-05", fmt(serial, "yyyy-mm-dd"));
ok("m/d(자리 없는 달·일)", fmt(serial, "m/d") === "3/5", fmt(serial, "m/d"));
const wd = new Date(Date.UTC(2026, 2, 5)).getUTCDay();
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
ok(
"요일 aaa",
fmt(serial, "aaa") === WEEKDAY_SHORT[wd],
`got=${fmt(serial, "aaa")} want=${WEEKDAY_SHORT[wd]}`,
);
ok("요일 aaaa", fmt(serial, "aaaa") === `${WEEKDAY_SHORT[wd]}요일`, fmt(serial, "aaaa"));
}
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",
@@ -156,6 +156,7 @@ ctx.root.append(grid.root);
grid,
sheet,
book,
ctx,
colIndex,
countCells: () => grid.root.querySelectorAll(".aislo-cell").length,
};
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""산출근거 스프레드시트 수식 분석 — 엑셀 규칙표.
선행 · 종속 추적(누를 때마다 한 단계 · 범위 화살 · 오류 칸 빨강 · 다른 시트 · 더 없으면 false) ·
수식 계산 걸음(왼쪽 먼저 · 빈 칸 0 · 범위는 함수가 · IF 첫 인자만 · 괄호 안 밑줄 · 오류 전파) ·
오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글).
화면(화살 겹판 · 계산 창 · ⚠ 풍선)은 harness_analysis.html 을 ORCA 로.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
GROUPS = ["trace", "eval_steps", "errcheck"]
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_spreadsheet_analysis.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] == []
@@ -0,0 +1,60 @@
/* =============================================================================
* test_stage3_autosum.ts — spreadsheet_autosum 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import { applyAutoSum, autoSumRange } from "../../../A00_Common/spreadsheet/spreadsheet_autosum.ts";
import { assertEqual, fracLiteral, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts";
// 위로 연속 숫자 셋 — A1,A2,A3 숫자 · A4(활성) 빈칸
const sheet = makeSheet("s1", {});
const book = makeBook([sheet]);
const ctxUp = makeCtx(book, "s1", {
s1: { A1: fracLiteral(1), A2: fracLiteral(2), A3: fracLiteral(3) },
});
ctxUp.selection.활성 = { r: 3, c: 0 };
ctxUp.selection.범위 = [{ r0: 3, c0: 0, r1: 3, c1: 0 }];
assertEqual(autoSumRange(ctxUp), { r0: 0, c0: 0, r1: 2, c1: 0 }, "위로 연속 숫자를 잡음");
applyAutoSum(ctxUp);
assertEqual(
ctxUp.applied.at(-1),
{ 종류: "칸", 시트: "s1", 칸: { A4: { 식: "SUM(A1:A3)" } } },
"applyAutoSum 이 칸 명령을 냄",
);
// 위가 빈칸 · 왼쪽으로 연속 숫자 — B1,C1 숫자 · D1(활성)
const ctxLeft = makeCtx(book, "s1", {
s1: { B1: fracLiteral(5), C1: fracLiteral(7) },
});
ctxLeft.selection.활성 = { r: 0, c: 3 };
ctxLeft.selection.범위 = [{ r0: 0, c0: 3, r1: 0, c1: 3 }];
assertEqual(
autoSumRange(ctxLeft),
{ r0: 0, c0: 1, r1: 0, c1: 2 },
"위가 없으면 왼쪽 연속 숫자를 잡음",
);
// 위 · 왼쪽 다 없음 — null
const ctxNone = makeCtx(book, "s1");
ctxNone.selection.활성 = { r: 5, c: 5 };
ctxNone.selection.범위 = [{ r0: 5, c0: 5, r1: 5, c1: 5 }];
assertEqual(autoSumRange(ctxNone), null, "위 · 왼쪽 다 없으면 null");
applyAutoSum(ctxNone);
assertEqual(
ctxNone.applied.at(-1),
{ 종류: "칸", 시트: "s1", 칸: { F6: { 식: "SUM()" } } },
"찾은 범위가 없으면 빈 SUM()",
);
// 여러 칸을 고른 채 — 활성이 맨 아래일 때 그 고름 안 위쪽만 잡음(가운데 빈칸이 있어도)
const ctxSel = makeCtx(book, "s1", { s1: { A10: fracLiteral(1), A11: fracLiteral(2) } });
ctxSel.selection.활성 = { r: 11, c: 0 };
ctxSel.selection.범위 = [{ r0: 9, c0: 0, r1: 11, c1: 0 }];
assertEqual(
autoSumRange(ctxSel),
{ r0: 9, c0: 0, r1: 10, c1: 0 },
"고른 범위 안에서 활성 위만 잡음",
);
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_autosum 시험 끝");
@@ -0,0 +1,36 @@
/* =============================================================================
* test_stage3_sheet_extra.ts — spreadsheet_sheet_extra 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import {
hideSheet,
setSheetTabColor,
unhideSheet,
} from "../../../A00_Common/spreadsheet/spreadsheet_sheet_extra.ts";
import { assertEqual, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts";
const s1 = makeSheet("s1", {});
const s2 = makeSheet("s2", {});
const book = makeBook([s1, s2]);
book.활성 = "s1";
const shown: string[] = [];
const ctx = { ...makeCtx(book, "s1"), showSheet: (id: string) => shown.push(id) };
assertEqual(hideSheet(ctx, "s1"), null, "hideSheet 성공 — 까닭 없음");
assertEqual(book.시트[0].숨김, true, "s1 이 숨겨짐");
assertEqual(ctx.applied.at(-1)?.종류, "시트통째", "hideSheet 은 시트통째로 나감");
assertEqual(shown, ["s2"], "활성 시트를 숨기면 다른 보이는 시트로 옮김");
assertEqual(hideSheet(ctx, "s2"), "마지막 보이는 시트는 못 숨김", "마지막 보이는 시트는 거절");
assertEqual(book.시트[1].숨김, undefined, "거절되면 안 바뀜");
unhideSheet(ctx, "s1");
assertEqual(book.시트[0].숨김, false, "unhideSheet 로 다시 보임");
setSheetTabColor(ctx, "s2", "#ff0000");
assertEqual(book.시트[1].탭색, "#ff0000", "탭 색 지정");
setSheetTabColor(ctx, "s2", null);
assertEqual(book.시트[1].탭색, undefined, "탭 색 지움");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_sheet_extra 시험 끝");
@@ -0,0 +1,47 @@
/* =============================================================================
* test_stage3_statusbar.ts — spreadsheet_statusbar 시험. 돌리기: npx tsx <이 파일>.
* ========================================================================== */
import { computeSelectionStats } from "../../../A00_Common/spreadsheet/spreadsheet_statusbar.ts";
import {
assertEqual,
fracLiteral,
makeBook,
makeCtx,
makeSheet,
v,
} from "./test_stage2_helpers.ts";
const sheet = makeSheet("s1", { A1: v("사과"), A2: v("배") });
const book = makeBook([sheet]);
const ctx = makeCtx(book, "s1", {
s1: { A1: fracLiteral(10), A2: fracLiteral(20), A3: "글자" },
});
ctx.selection.범위 = [{ r0: 0, c0: 0, r1: 2, c1: 0 }];
const stats = computeSelectionStats(ctx);
assertEqual(stats.합계, 10 + 20, "합계 — 숫자만 더함");
assertEqual(stats.평균, 15, "평균");
assertEqual(stats.개수, 3, "개수 — 빈칸 아닌 칸 전부(COUNTA)");
assertEqual(stats.숫자개수, 2, "숫자개수 — 숫자만(COUNT)");
assertEqual(stats.최소, 10, "최소");
assertEqual(stats.최대, 20, "최대");
// 빈 범위 — 다 null · 0
const empty = makeCtx(book, "s1");
empty.selection.범위 = [{ r0: 5, c0: 5, r1: 5, c1: 5 }];
const emptyStats = computeSelectionStats(empty);
assertEqual(emptyStats.합계, null, "빈 범위 합계는 null");
assertEqual(emptyStats.개수, 0, "빈 범위 개수는 0");
// 겹치는 범위는 한 번만 셈
const overlap = makeCtx(book, "s1", { s1: { A1: fracLiteral(5) } });
overlap.selection.범위 = [
{ r0: 0, c0: 0, r1: 0, c1: 0 },
{ r0: 0, c0: 0, r1: 0, c1: 0 },
];
assertEqual(computeSelectionStats(overlap).숫자개수, 1, "겹치는 범위는 한 번만 셈");
if (process.exitCode) process.exit(process.exitCode);
console.log("spreadsheet_statusbar 시험 끝");