/* ============================================================================= * spreadsheet_grid_cells.ts * 격자 칸 그리기 — 판 하나(틀고정 4 판 중)의 보이는 칸을 div 로 그림(칸 재사용 `CellPool`) · * 값 → 글(`formatValue`) · 서식 CSS · 수 `#####` · 긴 글 넘침(`spreadsheet_spill.ts`) 붙이기. * `spreadsheet_grid.ts` 가 판마다 부름 — 자리 재기 · 병합 · 확대는 `GridView` 로 받음. * ========================================================================== */ import { toFrac } from "@ui/sheet/ui_template_sheet_frac"; import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address"; import { applyCellStyle, cellStyleAt, isAutoInk, resolveBoxBorders, setCellText, type ResolvedBorders, } from "./spreadsheet_cellstyle"; import type { LineGeometry, OutlineRun } from "./spreadsheet_headers"; import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt"; import { formatValue } from "./spreadsheet_numfmt"; import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellRange, CellStyle, ErrorValue, HAlign, Scalar, Sheet, Workbook, } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; /** 격자가 지금 쓰는 자리 재기 · 병합 · 틀고정 — `createGrid` 의 값을 읽기만(바뀌면 새 값이 보임). */ export interface GridView { readonly colGeo: LineGeometry; readonly rowGeo: LineGeometry; readonly merges: Map; readonly extent: { rows: number; cols: number }; readonly zoom: number; readonly frozenRows: number; readonly frozenCols: number; readonly rowOutlineRuns: OutlineRun[]; readonly colOutlineRuns: OutlineRun[]; rowGutterW(): number; colGutterH(): number; } /** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */ export interface CellPool { used: Map; free: HTMLElement[]; } export const createPool = (): CellPool => ({ used: new Map(), free: [] }); function poolGet( pool: CellPool, key: string, container: HTMLElement, className: string, ): HTMLElement { const found = pool.used.get(key); if (found) return found; const node = pool.free.pop() ?? document.createElement("div"); node.className = className; pool.used.set(key, node); container.append(node); return node; } export function poolSweep(pool: CellPool, keep: Set): void { for (const [key, node] of pool.used) { if (keep.has(key)) continue; pool.used.delete(key); node.remove(); pool.free.push(node); } } function toScalar(value: number | string | boolean): Scalar { return typeof value === "number" ? toFrac(value) : value; } /** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */ export function computeExtent(sheet: Sheet): { rows: number; cols: number } { let maxR = 0; let maxC = 0; for (const key of Object.keys(sheet.칸)) { const addr = parseA1(key); if (!addr) continue; maxR = Math.max(maxR, addr.r); maxC = Math.max(maxC, addr.c); } for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1); for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key)); for (const m of sheet.병합 ?? []) { const range = parseRange(m); if (!range) continue; maxR = Math.max(maxR, range.r1); maxC = Math.max(maxC, range.c1); } return { rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)), cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)), }; } export function buildMergeMap(sheet: Sheet): Map { const map = new Map(); for (const m of sheet.병합 ?? []) { const range = parseRange(m); if (!range) continue; for (let r = range.r0; r <= range.r1; r += 1) { for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range); } } return map; } const SPILL_ALIGN: Partial> = { general: "left", left: "left", right: "right", center: "center", }; function resetSpill(cell: HTMLElement): void { cell.classList.remove("is-spill", "is-spill-r"); const t = cell.firstElementChild as HTMLElement | null; if (t) t.style.width = t.style.marginLeft = t.style.clipPath = ""; } let measureRuler: CanvasRenderingContext2D | null = null; /** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ function measureText(text: string, font: string): number { measureRuler ??= document.createElement("canvas").getContext("2d"); if (!measureRuler) return text.length * 7; measureRuler.font = font; return measureRuler.measureText(text).width; } /** 칸 글꼴 CSS 글(캔버스 재기용) — `applyCellStyle` 이 칸에 입히는 글꼴과 같은 차례로 고름. */ function cellFont(style: CellStyle, book: Workbook, scale: number): string { const family = style.글꼴 || book.기본?.글꼴 || "굴림"; const size = (style.크기 ?? book.기본?.크기 ?? 10) * scale; const weight = style.굵게 ? "700" : "400"; const italic = style.기울임 ? "italic " : ""; return `${italic}${weight} ${size}pt ${family}`; } /** 확대 · 축소 — 밑감(1 배) 자리 재기를 그대로 두고 픽셀만 곱함(수는 그대로 · 화면만 커짐). */ export function scaledGeometry(base: LineGeometry, factor: number): LineGeometry { if (factor === 1) return base; return { size: (i) => base.size(i) * factor, offset: (i) => base.offset(i) * factor, total: (i) => base.total(i) * factor, indexAt: (px) => base.indexAt(px / factor), }; } interface CellDisplay { text: string; hAlign: HAlign; title: string; isError: boolean; isNumeric: boolean; color?: string; } /** 칸 하나 값 — 식이면 엔진 값 · 아니면 입력 값(수는 Frac로) · 둘 다 없으면 빈 칸. */ function scalarAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): Scalar { const cell = sheet.칸[toA1(r, c)]; return cell?.식 !== undefined ? ctx.engine.value(sheet.id, r, c) : cell?.값 !== undefined ? toScalar(cell.값) : null; } /** 조건부 서식(sub4) 이 값을 보는 형 — Frac 는 근사 수로(조건 비교용 · 오류는 조건 대상 아님). */ function toCondFmtValue(scalar: Scalar): CondFmtValue { if (scalar === null || typeof scalar === "boolean" || typeof scalar === "string") return scalar; if ("error" in scalar) return null; return Number(scalar.n) / Number(scalar.d); } function cellDisplay( ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number, hAlign0: HAlign, code?: string, ): CellDisplay { const scalar = scalarAt(ctx, sheet, r, c); const formatted = formatValue(scalar, code); const isError = typeof scalar === "object" && scalar !== null && "error" in scalar; const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬; const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : ""; const isNumeric = !isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean"; return { text: formatted.글, hAlign, title, isError, isNumeric, color: formatted.색 }; } /** 판 하나의 보이는 칸 — 병합 칸은 div 하나 · 안 보이게 된 칸은 떼어 둠(다음에 다시 씀). */ export function renderCells( ctx: SpreadsheetContext, view: GridView, rowsVisible: number[], colsVisible: number[], pool: CellPool, container: HTMLElement, baseX: number, baseY: number, ): void { const { colGeo, rowGeo, merges, extent, zoom } = view; const sheet = ctx.sheet(); const book = ctx.book; const keep = new Set(); // 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서) const slots = new Map>(); const longs: { r: number; c: number; el: HTMLElement; own: number; need: number; align: SpillAlign; }[] = []; for (const r of rowsVisible) { for (const c of colsVisible) { const merge = merges.get(`${r},${c}`); if (merge && (merge.r0 !== r || merge.c0 !== c)) continue; const r0 = merge ? merge.r0 : r; const c0 = merge ? merge.c0 : c; const r1 = merge ? merge.r1 : r; const c1 = merge ? merge.c1 : c; const key = `${r0},${c0}`; if (keep.has(key)) continue; keep.add(key); const cellEl = poolGet(pool, key, container, "aislo-cell"); const x = colGeo.offset(c0) - baseX; const y = rowGeo.offset(r0) - baseY; const w = colGeo.offset(c1 + 1) - colGeo.offset(c0); const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0); cellEl.style.left = `${x}px`; cellEl.style.top = `${y}px`; cellEl.style.width = `${w}px`; cellEl.style.height = `${h}px`; cellEl.dataset.r = String(r0); cellEl.dataset.c = String(c0); const addr = toA1(r0, c0); cellEl.dataset.addr = addr; const baseStyle = cellStyleAt(book, sheet, r0, c0); const condStyle = sheet.조건부서식?.length ? resolveCondFmtStyle(sheet, r0, c0, { value: (rr, cc) => toCondFmtValue(scalarAt(ctx, sheet, rr, cc)), }) : null; const style = condStyle ? { ...baseStyle, ...condStyle } : baseStyle; const borders: ResolvedBorders = resolveBoxBorders( book, sheet, r0, c0, r1, c1, extent.rows - 1, extent.cols - 1, ); const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식); applyCellStyle(cellEl, style, borders, display.hAlign, zoom); // 조건부 서식 글자색이 이기고(엑셀과 같음) · 없으면 숫자 형식([Red] 등)이 정한 색. if (display.color && !condStyle?.글자색 && !isAutoInk(display.color)) cellEl.style.color = display.color; const comment = sheet.comments?.[addr]; // 수는 칸 폭보다 길면 `#####`(엑셀과 같음) · 글은 지금처럼 줄임표. let text = display.text; if (display.isNumeric && !style.줄바꿈) { const font = cellFont(style, book, zoom); const avail = w - 4; if (measureText(text, font) > avail) { const hashW = measureText("#", font) || 1; text = "#".repeat(Math.max(1, Math.floor(avail / hashW))); } } setCellText(cellEl, text, display.title || comment || undefined); resetSpill(cellEl); if (!merge) { (slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" }); const align = SPILL_ALIGN[display.hAlign]; if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) { // 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로 const need = measureText(text, cellFont(style, book, zoom)) + 4; if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); } } cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( "is-selected", ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0, ); } } // 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산 for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5; for (const l of longs) { const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align); if (!sp) continue; l.el.classList.add("is-spill"); const t = l.el.firstElementChild as HTMLElement; t.style.width = `calc(100% + ${sp.left + sp.right}px)`; t.style.marginLeft = `${-sp.left}px`; if (sp.clipLeft || sp.clipRight) t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`; for (let k = sp.from; k < sp.to; k++) pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r"); } poolSweep(pool, keep); }