fix(m01-sl): 표 상자에 표 번호 안 보이게 — 시트 이름 · 찾기 판 머리를 표 이름으로 · 표 줄 수 따라가기

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
This commit is contained in:
2026-10-01 13:53:46 +09:00
co-authored by Claude Sonnet 5.5
parent a02ea70d2e
commit 2c848702ea
2 changed files with 88 additions and 15 deletions
+43 -14
View File
@@ -1,12 +1,12 @@
/* =============================================================================
* M01_SL_Table_Build.ts
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
* 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 합친 조건 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts`
*
* 시트 모양(표 상자 시트 하나):
* A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움
* 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로)
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
* 빈 열 하나 건너 = 찾기용 합친 조건 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 합친 조건 열이 따로)
* 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 합친 조건 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다)
* 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A
* ========================================================================== */
@@ -58,7 +58,7 @@ export interface TblPlan {
outCells: Record<string, string>;
idxCell: string;
statusCell: string;
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */
/** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 합친 조건 열은 다시 쓸 때 지움 */
helperFrom: number;
warnings: string[];
}
@@ -74,7 +74,8 @@ const colIndex = (letters: string): number =>
[...letters].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1;
const at = (r: number, c: number): string => `${colName(c)}${r}`;
const MARGIN = 20;
/** 표 줄이 늘어도 따라가도록 블록 아래로 여유 줄을 둠 — 줄 수 이상 · 최소 20 */
const marginOf = (n: number): number => Math.max(20, n);
const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`;
/** 범위 칸인가 — 조건 칸이 「범위」 이거나 값 칸에 범위(배열) 줄이 하나라도 있으면(서버 표 블록과 같은 규칙) */
@@ -148,16 +149,16 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음");
const rule = info.rule ?? "초과·이하";
const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule));
const R = (range ? n : n + MARGIN) + 1;
const R = n + marginOf(n) + 1;
const rng = (L: string): string => `${L}$2:${L}$${R}`;
// 열쇠 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
// 합친 조건 열 — 맨 앞은 모든 조건 칸 · 이어서 빈칸(무관) 변형들
const variants: string[][] = [[]];
if (!range) {
const bl = blankable.slice(0, 2);
const subsets =
bl.length === 2 ? [[bl[0]], [bl[1]], [bl[0], bl[1]]] : bl.length === 1 ? [[bl[0]]] : [];
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 열쇠가 없음
variants.push(...subsets.filter((sub) => sub.length < exact.length)); // 모든 조건 칸이 빈칸인 변형은 합친 조건가 없음
}
const keyCols = exact.length ? variants : [];
const kStart = W + 1;
@@ -166,7 +167,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
const V = L + 1;
const cells: Record<string, Cell> = {};
keyCols.forEach((skip, v) => {
cells[at(1, kStart + v)] = { 값: skip.length ? `열쇠(${skip.join("·")} 무관)` : "열쇠" };
cells[at(1, kStart + v)] = {
값: skip.length ? `합친 조건(${skip.join("·")} 무관)` : "합친 조건",
};
const rest = exact.filter((c) => !skip.includes(c));
for (let r = 2; r <= R; r++) {
const key = rest.map((c) => `${letter(c)}${r}`).join('&"|"&');
@@ -183,7 +186,9 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
// 찾기 판
let row = 1;
cells[at(row++, L)] = { 값: "표에서 찾기" };
cells[at(row++, L)] = {
값: ["표에서 찾기", info.name, info.no, info.base].filter(Boolean).join(" · "),
};
const inCells: Record<string, string> = {};
const inputOf: Record<string, string> = {};
for (const c of conds) {
@@ -194,6 +199,14 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
inputOf[c] = at(row, V);
row++;
}
// 표 줄 수 — 블록 안 칸이 찬 줄 수(마스터 표 줄이 늘거나 줄어도 따라감)
const rowsCell = ((): string => {
cells[at(row, L)] = { 값: "표 줄 수" };
cells[at(row, V)] = {
식: `MAX(${cols.map((_, c) => `COUNTA(${rng(colName(c))})`).join(",")})`,
};
return at(row++, V);
})();
const keyOf = (skip: string[]): string =>
exact
.filter((c) => !skip.includes(c))
@@ -210,10 +223,10 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
let idx = "";
let count = "";
if (exact.length) {
const keyCell = idxRow("열쇠", keyOf([]));
const keyCell = idxRow("합친 조건", keyOf([]));
const ks = keyCols.map((skip, v) => {
if (v === 0) return keyCell;
cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` };
cells[at(row, L)] = { 값: `합친 조건(${skip.join("·")} 무관)` };
cells[at(row, V)] = { 식: keyOf(skip) };
return at(row++, V);
});
@@ -224,7 +237,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
);
count = idxRow(
"일치 수",
`IF(ISNUMBER(${idx}),IF(${idx}>${n},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
`IF(ISNUMBER(${idx}),IF(${idx}>${rowsCell},0,IF(AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx}))),1,0)),0)`,
);
} else {
const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`);
@@ -242,7 +255,7 @@ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan {
const inside = lowBased
? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<INDEX(${up},${idx})))`
: `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${idx})),${x}<=INDEX(${up},${idx})))`;
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${n}),IF(${inside},1,0),0)`);
count = idxRow("일치 수", `IF(AND(${idx}>=1,${idx}<=${rowsCell}),IF(${inside},1,0),0)`);
} else {
warnings.push("조건 칸이 없음 — 찾을 줄이 없음");
count = idxRow("일치 수", "0");
@@ -294,6 +307,21 @@ export function pillsOf(
const sheetIndexOf = (sl: SheetLogic, node: SlNode): number =>
sl.workbook.시트.findIndex((s) => s.id === node.sheetId);
/** 시트 이름을 바꿈 — 같은 이름이 있으면 번호를 붙이고 · 다른 시트가 이 시트를 가리키던 참조 수식도 따라 고침 */
function renameSheet(sl: SheetLogic, sheet: Sheet, want: string): void {
if (sheet.이름 === want) return;
const taken = new Set(sl.workbook.시트.filter((s) => s !== sheet).map((s) => s.이름));
let name = want;
for (let i = 2; taken.has(name); i++) name = `${want} (${i})`;
const quote = (n: string): string => `'${n.replace(/'/g, "''")}'`;
const old = sheet.이름;
const olds = [`${quote(old)}!`, ...(/^[\p{L}\p{N}_]+$/u.test(old) ? [`${old}!`] : [])];
for (const s of sl.workbook.시트)
for (const cell of Object.values(s.칸))
if (cell.식) for (const o of olds) cell.식 = cell.식.split(o).join(`${quote(name)}!`);
sheet.이름 = name;
}
/** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */
export function applyTable(
sl: SheetLogic,
@@ -331,6 +359,7 @@ export function applyTable(
!(e.to.node === nodeId && gone("in", e.to.pill)),
);
node.name = `표 · ${info.name}`;
renameSheet(next, sheet, node.name);
node.ref = opts.ref;
node.pillsIn = pills.pillsIn;
node.pillsOut = pills.pillsOut;