/* ============================================================================= * M01_SL_Table_Build.ts * 표 상자 짜기 — 표 한 벌 → 시트 칸(찾기용 열쇠 · 입력 칸 · 걸린 줄 · 상태 · 결과 칸 수식) · 알약 · 붙여넣기 읽기 * DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M01_SL_Table.ts` * * 시트 모양(표 상자 시트 하나): * A1 부터 = 표 블록(머리 줄 + 줄 · 범위 조건 「3~4」 는 「이름·아래」 「이름·위」 두 칸) — 품셈 표는 바깥 값이 채움 * 빈 열 하나 건너 = 찾기용 열쇠 열(조건 칸을 「|」 로 이은 글 · 빈칸 = 무관 줄은 그 칸을 뺀 열쇠 열이 따로) * 다시 빈 열 하나 건너 = 찾기 판 — 입력(조건 칸마다) · 열쇠 · 일치 수 · 걸린 줄 · 상태 · 결과(결과 칸마다) * 맞는 줄 없음 · 둘 이상이면 상태 칸이 글로 알리고 결과 칸은 #N/A * ========================================================================== */ import type { Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { SheetLogic, SlNode, SlPill } from "./M01_SL_Model"; type Rec = Record; /** 표 한 벌 — 서버 `/table` 답에서 쓸 칸만 · 내 표는 시트에서 읽어 만듦 */ export interface TblInfo { key: string; name: string; /** 기준 「1㎡」 */ base: string; source: string; /** 원문번호 「13-4-1」 */ no: string; /** 조건 칸 → 「수」 「고르기」 「범위」 */ conds: Record; /** 값 칸 → 단위 */ vals: Record; rule: string | null; rows: Rec[]; notes: string[]; } /** 칸 역할 — 조건 · 결과(나머지는 쓰지 않음) */ export interface TblRoles { cond: string[]; out: string[]; } /** 블록 열 하나 — 범위 칸은 아래 · 위 두 열 */ export interface TblCol { header: string; of: string; part: "값" | "아래" | "위"; } export interface TblPlan { cols: TblCol[]; /** 블록 안 줄 수(머리 줄 빼고) */ rowCount: number; /** 블록 밖에 쓸 칸 — A1 → 칸 */ cells: Record; /** 조건 칸 이름 → 입력 칸 */ inCells: Record; /** 결과 칸 이름 → 출력 칸 */ outCells: Record; idxCell: string; statusCell: string; /** 찾기 판 왼쪽 첫 열(0 부터) — 이 열 앞 블록 · 열쇠 열은 다시 쓸 때 지움 */ helperFrom: number; warnings: string[]; } /** 열 글자 0 → A */ export const colName = (c: number): string => { let s = ""; for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s; return s; }; 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; const quote = (s: string): string => `"${s.replace(/"/g, '""')}"`; /** 블록 열 차례 — 조건 칸 · 값 칸 차례 그대로 · 범위 칸은 둘로 */ export function layoutOf(info: TblInfo): TblCol[] { const out: TblCol[] = []; for (const c of Object.keys(info.conds)) if (info.conds[c] === "범위") out.push( { header: `${c}·아래`, of: c, part: "아래" }, { header: `${c}·위`, of: c, part: "위" }, ); else out.push({ header: c, of: c, part: "값" }); for (const c of Object.keys(info.vals)) out.push({ header: c, of: c, part: "값" }); return out; } const cellOf = (v: Rec[string]): Cell | null => v === null || v === undefined || v === "" ? null : typeof v === "number" ? { 값: v } : { 값: String(v) }; /** 블록 칸 — 머리 줄 + 줄(내 표 · 시험이 씀 · 품셈 표는 바깥 값이 채움) */ export function blockCells(info: TblInfo): Record { const cells: Record = {}; const cols = layoutOf(info); cols.forEach((col, c) => (cells[at(1, c)] = { 값: col.header })); info.rows.forEach((row, i) => cols.forEach((col, c) => { const v = row[col.of]; const x = Array.isArray(v) ? col.part === "아래" ? v[0] : v[1] : col.part === "값" ? v : null; const cell = cellOf(x); if (cell) cells[at(i + 2, c)] = cell; }), ); return cells; } /** 조건 칸 하나의 입력 칸 처음 값 — 첫 줄 */ const sampleOf = (info: TblInfo, c: string): string | number => { const v = info.rows[0]?.[c]; if (Array.isArray(v)) return v[0] !== null && v[1] !== null ? Number(((v[0] + v[1]) / 2).toFixed(6)) : (v[1] ?? v[0] ?? 0); return v === null || v === undefined ? "" : v; }; /** 표 상자 시트에 쓸 찾기 칸 · 알약 계획 */ export function planLookup(info: TblInfo, roles: TblRoles): TblPlan { const cols = layoutOf(info); const warnings: string[] = []; const W = cols.length; const n = info.rows.length; const letter = (header: string): string => colName(cols.findIndex((c) => c.header === header)); const conds = roles.cond.filter((c) => c in info.conds); const rangeCols = conds.filter((c) => info.conds[c] === "범위"); const exact = conds.filter((c) => info.conds[c] !== "범위"); const range = rangeCols[0]; if (rangeCols.length > 1) warnings.push("범위 조건은 한 칸까지만 찾음 — 나머지는 뺌"); const blankable = exact.filter((c) => info.rows.some((r) => r[c] === null || r[c] === undefined || r[c] === ""), ); if (blankable.length > 2) warnings.push("빈칸(무관) 조건 칸은 둘까지만 지원"); if (range && blankable.length) warnings.push("범위 조건과 빈칸(무관)은 함께 못 씀 — 빈칸 줄은 맞지 않음"); const rule = info.rule ?? "초과·이하"; const lowBased = /이상·미만/.test(rule) || (/^이상/.test(rule) && !/초과/.test(rule)); const R = (range ? n : n + MARGIN) + 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); } const keyCols = exact.length ? variants : []; const kStart = W + 1; const kLetters = keyCols.map((_, i) => colName(kStart + i)); const L = kStart + keyCols.length + 1; // 찾기 판 왼쪽 열 const V = L + 1; const cells: Record = {}; keyCols.forEach((skip, v) => { 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('&"|"&'); if (!skip.length) cells[at(r, kStart + v)] = { 식: key }; else { const guard = [ ...skip.map((c) => `${letter(c)}${r}=""`), ...rest.map((c) => `${letter(c)}${r}<>""`), ].join(","); cells[at(r, kStart + v)] = { 식: `IF(AND(${guard}),${key},"")` }; } } }); // 찾기 판 let row = 1; cells[at(row++, L)] = { 값: "표에서 찾기" }; const inCells: Record = {}; const inputOf: Record = {}; for (const c of conds) { cells[at(row, L)] = { 값: c }; const sample = sampleOf(info, c); if (sample !== "") cells[at(row, V)] = { 값: sample }; inCells[c] = at(row, V); inputOf[c] = at(row, V); row++; } const keyOf = (skip: string[]): string => exact .filter((c) => !skip.includes(c)) .map((c) => inputOf[c]) .join('&"|"&'); const idxRow = (label: string, f: string): string => { cells[at(row, L)] = { 값: label }; cells[at(row, V)] = { 식: f }; return at(row++, V); }; const up = range ? rng(letter(`${range}·위`)) : ""; const lo = range ? rng(letter(`${range}·아래`)) : ""; const x = range ? inputOf[range] : ""; let idx = ""; let count = ""; if (exact.length) { const keyCell = idxRow("열쇠", keyOf([])); const ks = keyCols.map((skip, v) => { if (v === 0) return keyCell; cells[at(row, L)] = { 값: `열쇠(${skip.join("·")} 무관)` }; cells[at(row, V)] = { 식: keyOf(skip) }; return at(row++, V); }); if (range) { idx = idxRow( "걸린 줄", `MATCH(${ks[0]},${rng(kLetters[0])},0)+COUNTIFS(${rng(kLetters[0])},${ks[0]},${up},"<"&${x})`, ); 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)`, ); } else { const found = ks.map((k, v) => `COUNTIF(${rng(kLetters[v])},${k})`); count = idxRow( "일치 수", found.reduceRight((acc, f) => `IF(${f}>0,${f},${acc})`, "0"), ); const chain = ks .map((k, v) => `MATCH(${k},${rng(kLetters[v])},0)`) .reduceRight((acc, m) => `IFERROR(${m},${acc})`, "NA()"); idx = idxRow("걸린 줄", chain); } } else if (range) { idx = idxRow("걸린 줄", lowBased ? `COUNTIF(${lo},"<="&${x})` : `COUNTIF(${up},"<"&${x})+1`); const inside = lowBased ? `AND(OR(ISBLANK(INDEX(${lo},${idx})),${x}>=INDEX(${lo},${idx})),OR(ISBLANK(INDEX(${up},${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)`); } else { warnings.push("조건 칸이 없음 — 찾을 줄이 없음"); count = idxRow("일치 수", "0"); idx = idxRow("걸린 줄", "NA()"); } if (lowBased && range && exact.length) warnings.push("조건이 섞이면 범위는 「초과·이하」 로만 찾음"); const statusCell = idxRow( "상태", `IF(${count}=0,${quote("맞는 줄 없음")},IF(${count}>1,${quote("맞는 줄 둘 이상")},${quote("찾음")}))`, ); const outCells: Record = {}; for (const c of roles.out.filter((o) => o in info.vals)) { outCells[c] = idxRow( c, `IF(${statusCell}=${quote("찾음")},INDEX(${rng(letter(c))},${idx}),NA())`, ); } return { cols, rowCount: n, cells, inCells, outCells, idxCell: idx, statusCell, helperFrom: W, warnings, }; } /** 알약 — 조건 칸마다 입력 알약 · 결과 칸마다 출력 알약 · 바깥 값 표 알약(품셈 표만) */ export function pillsOf( info: TblInfo, plan: TblPlan, outside: boolean, ): { pillsIn: SlPill[]; pillsOut: SlPill[] } { const pillsIn = Object.entries(plan.inCells).map(([name, cell]): SlPill => ({ name, cell })); const pillsOut = Object.entries(plan.outCells).map(([name, cell]): SlPill => ({ name, cell, unit: info.vals[name] || undefined, })); if (outside) pillsOut.push({ name: "표", cell: "A1", outside: true }); return { pillsIn, pillsOut }; } const sheetIndexOf = (sl: SheetLogic, node: SlNode): number => sl.workbook.시트.findIndex((s) => s.id === node.sheetId); /** 표 상자에 표 · 찾기 판을 씀 — 새 시트 로직(받은 것은 안 바꿈) · `fillBlock` 이면 블록도 씀(내 표 · 시험) */ export function applyTable( sl: SheetLogic, nodeId: string, info: TblInfo, roles: TblRoles, opts: { ref: string; fillBlock: boolean; outside: boolean }, ): { sl: SheetLogic; plan: TblPlan } { const next = structuredClone(sl); const node = next.nodes.find((n) => n.id === nodeId); if (!node) throw new Error("없는 상자"); const si = sheetIndexOf(next, node); if (si < 0) throw new Error("상자의 시트가 없음"); const plan = planLookup(info, roles); const sheet: Sheet = next.workbook.시트[si]; const keep = Object.entries(sheet.칸).filter( ([a]) => colIndex(a.replace(/\d+/g, "")) < plan.helperFrom, ); sheet.칸 = Object.fromEntries(keep); if (opts.fillBlock) { for (const a of Object.keys(sheet.칸)) delete sheet.칸[a]; Object.assign(sheet.칸, blockCells(info)); } Object.assign(sheet.칸, plan.cells); const pills = pillsOf(info, plan, opts.outside); const gone = (dir: "in" | "out", name: string): boolean => !(dir === "in" ? pills.pillsIn : pills.pillsOut).some((p) => p.name === name); next.edges = next.edges.filter( (e) => !(e.from.node === nodeId && gone("out", e.from.pill)) && !(e.to.node === nodeId && gone("in", e.to.pill)), ); node.name = `표 · ${info.name}`; node.ref = opts.ref; node.pillsIn = pills.pillsIn; node.pillsOut = pills.pillsOut; return { sl: next, plan }; } /** 엑셀에서 붙여넣은 글(탭 · 줄바꿈) → 머리 · 줄 — 「3~4」 꼴 칸 열은 아래 · 위 범위 칸으로 */ export function parsePaste(text: string): { info: TblInfo; error: string | null } { const lines = text .replace(/\r/g, "") .split("\n") .filter((l) => l.trim() !== ""); const empty: TblInfo = { key: "", name: "", base: "", source: "", no: "", conds: {}, vals: {}, rule: null, rows: [], notes: [], }; if (lines.length < 2) return { info: empty, error: "머리 한 줄과 줄 하나는 있어야 함" }; const grid = lines.map((l) => l.split("\t").map((c) => c.trim())); const head = grid[0]; if (head.some((h) => h === "") || new Set(head).size !== head.length) return { info: empty, error: "머리 칸 이름이 비었거나 겹침" }; const num = (s: string): number | null => s !== "" && Number.isFinite(Number(s.replace(/,/g, ""))) ? Number(s.replace(/,/g, "")) : null; const rangeRe = /^(-?\d+(?:\.\d+)?)?\s*[~∼]\s*(-?\d+(?:\.\d+)?)?$/; const body = grid.slice(1); const isRange = (c: number): boolean => body.some((r) => (r[c] ?? "").includes("~") || (r[c] ?? "").includes("∼")) && body.every((r) => !r[c] || rangeRe.test(r[c])); const rows: Rec[] = body.map((r) => { const rec: Rec = {}; head.forEach((h, c) => { const s = r[c] ?? ""; if (isRange(c)) { const m = rangeRe.exec(s); rec[h] = m ? [m[1] === undefined ? null : Number(m[1]), m[2] === undefined ? null : Number(m[2])] : null; } else rec[h] = s === "" ? null : (num(s) ?? s); }); return rec; }); // 첫 칸부터 글 · 수 가운데 숫자가 아닌 칸 · 범위 칸이 조건 · 나머지(수만 든 칸)는 값 const conds: Record = {}; const vals: Record = {}; head.forEach((h, c) => { const numeric = body.every((r) => !r[c] || num(r[c]) !== null); if (isRange(c)) conds[h] = "범위"; else if (!numeric) conds[h] = "고르기"; else if (c === 0 && head.length > 1) conds[h] = "수"; else vals[h] = ""; }); if (!Object.keys(vals).length) return { info: empty, error: "결과로 쓸 숫자 칸이 없음" }; return { info: { ...empty, conds, vals, rows }, error: null }; } /** 내 표 — 시트 블록(A1 머리 줄 · 아래로 줄)에서 표 한 벌을 읽음 · 「이름·아래」 「이름·위」 짝은 범위 칸 */ export function infoFromSheet(sheet: Sheet, prev: Pick): TblInfo { const heads: string[] = []; for (let c = 0; ; c++) { const h = sheet.칸[at(1, c)]?.값; if (h === undefined || h === "") break; heads.push(String(h)); } let last = 1; for (const a of Object.keys(sheet.칸)) { const m = /^([A-Z]+)(\d+)$/.exec(a); if (m && colIndex(m[1]) < heads.length) last = Math.max(last, Number(m[2])); } const conds: Record = {}; const vals: Record = {}; const names: { of: string; part: "값" | "아래" | "위"; c: number }[] = []; heads.forEach((h, c) => { const m = /^(.+)·(아래|위)$/.exec(h); names.push(m ? { of: m[1], part: m[2] as "아래" | "위", c } : { of: h, part: "값", c }); }); for (const nm of names) { if (nm.part !== "값") conds[nm.of] = "범위"; else if (prev.conds[nm.of]) conds[nm.of] = prev.conds[nm.of]; else vals[nm.of] = ""; } const rows: Rec[] = []; for (let r = 2; r <= last; r++) { const rec: Rec = {}; for (const nm of names) { const v = sheet.칸[at(r, nm.c)]?.값; const x = v === undefined ? null : typeof v === "boolean" ? String(v) : v; if (nm.part === "값") rec[nm.of] = x; else { const prevPair = (rec[nm.of] as [number | null, number | null] | undefined) ?? [null, null]; prevPair[nm.part === "아래" ? 0 : 1] = typeof x === "number" ? x : null; rec[nm.of] = prevPair; } } rows.push(rec); } return { key: "", name: prev.name, base: "", source: "", no: "", conds, vals, rule: null, rows, notes: [], }; }