/** * Grid Rendering — builds the spreadsheet HTML table and renders it to the DOM. * * Extracted from main.ts for decomposition. */ import { cellId, colToLetter } from './formulas.js'; import { evaluateRules, buildCfStyle, computeColorScale, computeDataBars, computeIconSets } from './conditional-format.js'; import type { DataBarResult, IconSetResult } from './types.js'; import { getErrorInfo } from './error-tooltips.js'; import { renderInteractiveCell } from './rich-cells.js'; import { getStripedRowClass } from './cell-styles.js'; import { getCellBgColor, getCellStyle } from './cell-style-utils.js'; import { computeVisibleRows, computeVisibleCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; import { isSparklineResult, drawSparkline } from './sparkline.js'; import { escapeHtml } from '../lib/escape-html.js'; import { validateCell } from './data-validation.js'; import { isInRange } from './selection-utils.js'; // ── Types ─────────────────────────────────────────────────── export interface GridRenderingDeps { grid: HTMLElement; getActiveSheet: () => any; getCellData: (id: string) => any; computeDisplayValue: (id: string, data: any) => any; getColWidth: (col: number) => number; getRowHeight: (row: number) => number; getFreezeRows: () => number; getFreezeCols: () => number; getCfRulesArray: () => any[]; getStripedRows: () => boolean; getValidationForCell: (id: string) => any; buildMergeMap: () => Map; buildHiddenRowSet: () => { has: (r: number) => boolean }; buildHiddenColSet: () => { has: (c: number) => boolean }; isSpillSource: (id: string) => boolean; isSpillTarget: (id: string) => boolean; sheetsFindState: any; getEditingCell: () => any; getSelectedCell: () => { col: number; row: number }; getSelectionRange: () => any; refreshVisibleCells: () => void; updateSelectionVisuals: () => void; updateFreezeToolbarState: () => void; renderNoteIndicators: () => void; renderImageCells: () => void; attachGridEvents: () => void; DEFAULT_ROWS: number; DEFAULT_COLS: number; MIN_COL_WIDTH: number; ROW_HEADER_WIDTH: number; } // ── State ─────────────────────────────────────────────────── let _renderGridTimer: number | null = null; let _isRendering = false; let _gridEventsAttached = false; // ── Functions ─────────────────────────────────────────────── export function scheduleRenderGrid(deps: GridRenderingDeps): void { if (_renderGridTimer) return; _renderGridTimer = requestAnimationFrame(() => { _renderGridTimer = null; if (deps.getEditingCell()) { deps.refreshVisibleCells(); return; } renderGrid(deps); }); } export function renderGrid(deps: GridRenderingDeps): void { _isRendering = true; const { grid, getActiveSheet, getCellData, computeDisplayValue, getColWidth, getRowHeight, getFreezeRows, getFreezeCols, getCfRulesArray, getStripedRows, getValidationForCell, buildMergeMap, buildHiddenRowSet, buildHiddenColSet, isSpillSource, isSpillTarget, sheetsFindState, getSelectedCell, getSelectionRange, updateSelectionVisuals, updateFreezeToolbarState, renderNoteIndicators, renderImageCells, attachGridEvents, DEFAULT_ROWS, DEFAULT_COLS, MIN_COL_WIDTH, ROW_HEADER_WIDTH, } = deps; const sheet = getActiveSheet(); const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; const colCount = sheet.get('colCount') || DEFAULT_COLS; const freezeR = getFreezeRows(); const freezeC = getFreezeCols(); const mergeMap = buildMergeMap(); const hiddenRowSet = buildHiddenRowSet(); const hiddenColSet = buildHiddenColSet(); const { cols: visibleColList, indicators: colIndicators } = computeVisibleCols(1, colCount, hiddenColSet); const visibleColSet = new Set(visibleColList); const frozenLeftOffsets = [0]; let cumLeft = ROW_HEADER_WIDTH; for (let c = 1; c <= freezeC; c++) { frozenLeftOffsets.push(cumLeft); if (visibleColSet.has(c)) cumLeft += getColWidth(c); } const headerRowHeight = 26; const bodyRowHeight = 26; const rh = (row: number): number => getRowHeight(row); const frozenRowTopOffsets: number[] = [0]; let cumTop = headerRowHeight; for (let r = 1; r <= freezeR; r++) { frozenRowTopOffsets.push(cumTop); cumTop += rh(r); } // --- Build colgroup + thead --- let headHtml = ''; headHtml += ''; for (let c = 1; c <= colCount; c++) { if (!visibleColSet.has(c)) continue; const w = getColWidth(c); headHtml += ''; } headHtml += ''; headHtml += ''; const cornerCls = ['corner']; if (freezeR > 0) cornerCls.push('freeze-border-bottom'); if (freezeC > 0) cornerCls.push('freeze-border-right'); headHtml += ''; const colIndicatorAfter = new Set(); for (const ind of colIndicators) colIndicatorAfter.add(ind.afterCol); for (let c = 1; c <= colCount; c++) { if (!visibleColSet.has(c)) continue; const cls: string[] = []; if (c <= freezeC) { cls.push('frozen-col'); if (c === freezeC) cls.push('freeze-border-right'); } if (freezeR > 0) cls.push('freeze-border-bottom'); if (isAtHiddenColBoundary(c, colCount, hiddenColSet)) cls.push('hidden-col-boundary'); const leftStyle = c <= freezeC ? 'left:' + frozenLeftOffsets[c] + 'px;' : ''; const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; const colUnhideIndicator = cls.includes('hidden-col-boundary') ? '
' : ''; headHtml += '' + colToLetter(c) + '
' + colUnhideIndicator + ''; } headHtml += ''; // --- Build tbody --- let tbodyHtml = ''; const cfRules = getCfRulesArray(); const stripedEnabled = getStripedRows(); // Collect cell values once for all range-based CF rules (color scale, data bars, icon sets) const colorScaleRule = cfRules.find(r => r.type === 'colorScale'); const dataBarRule = cfRules.find(r => r.type === 'dataBar'); const iconSetRule = cfRules.find(r => r.type === 'iconSet'); let colorScaleStyles: Map | null = null; let dataBarStyles: Map | null = null; let iconSetStyles: Map | null = null; if (colorScaleRule || dataBarRule || iconSetRule) { const allCellValues = new Map(); for (let r = 1; r <= rowCount; r++) { for (let c = 1; c <= colCount; c++) { const id = cellId(c, r); const cd = getCellData(id); if (cd) allCellValues.set(id, computeDisplayValue(id, cd)); } } if (colorScaleRule) colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); if (dataBarRule) dataBarStyles = computeDataBars(allCellValues, dataBarRule); if (iconSetRule) iconSetStyles = computeIconSets(allCellValues, iconSetRule); } const allRowsToRender: number[] = []; for (let r = 1; r <= rowCount; r++) { if (!hiddenRowSet.has(r)) allRowsToRender.push(r); } const { indicators: rowIndicators } = computeVisibleRows(1, rowCount, hiddenRowSet); const rowIndicatorMap = new Map(); for (const ind of rowIndicators) rowIndicatorMap.set(ind.afterRow, ind.hiddenCount); const findActive = sheetsFindState.matches.length > 0; const selectedCell = getSelectedCell(); const editingCell = deps.getEditingCell(); const selectionRange = getSelectionRange(); if (rowIndicatorMap.has(0)) { const count = rowIndicatorMap.get(0); tbodyHtml += '
'; } for (const r of allRowsToRender) { const rowH = rh(r); tbodyHtml += ''; const rhCls = ['row-header']; if (r <= freezeR) { rhCls.push('frozen-row'); if (r === freezeR) rhCls.push('freeze-border-bottom'); } if (isAtHiddenRowBoundary(r, rowCount, hiddenRowSet)) rhCls.push('hidden-row-boundary'); const rhTop = r <= freezeR ? 'top:' + frozenRowTopOffsets[r] + 'px;' : ''; const rhHeight = rowH !== bodyRowHeight ? 'height:' + rowH + 'px;' : ''; tbodyHtml += '' + r + '
'; for (let c = 1; c <= colCount; c++) { if (!visibleColSet.has(c)) continue; const id = cellId(c, r); const mergeInfo = mergeMap.get(id); if (mergeInfo && mergeInfo.hidden) continue; const cellData = getCellData(id); const displayValue = computeDisplayValue(id, cellData); // getCellClasses inline const tdCls: string[] = []; if (selectedCell.col === c && selectedCell.row === r) tdCls.push('selected'); if (editingCell && editingCell.col === c && editingCell.row === r) tdCls.push('editing'); if (isInRange(c, r, selectionRange)) tdCls.push('in-range'); if (r <= freezeR && c <= freezeC) { tdCls.push('frozen-corner'); if (r === freezeR) tdCls.push('freeze-border-bottom'); if (c === freezeC) tdCls.push('freeze-border-right'); } else if (r <= freezeR) { tdCls.push('frozen-row'); if (r === freezeR) tdCls.push('freeze-border-bottom'); } else if (c <= freezeC) { tdCls.push('frozen-col'); if (c === freezeC) tdCls.push('freeze-border-right'); } if (mergeInfo && !mergeInfo.hidden) tdCls.push('merged-cell'); const stripeClass = getStripedRowClass(r, stripedEnabled); if (stripeClass) tdCls.push(stripeClass); const validation = getValidationForCell(id); let validationMsg = ''; if (validation && cellData) { const valResult = validateCell(displayValue, validation); if (!valResult.valid) { tdCls.push('validation-invalid'); validationMsg = valResult.message || 'Invalid value'; } } if (isSpillSource(id)) tdCls.push('spill-source'); if (isSpillTarget(id)) tdCls.push('spill-target'); if (findActive) { if (isCurrentMatch(sheetsFindState, id)) tdCls.push('find-match-active'); else if (isCellMatch(sheetsFindState, id)) tdCls.push('find-match'); } let tdStyle = ''; if (r <= freezeR) tdStyle += 'top:' + frozenRowTopOffsets[r] + 'px;'; if (c <= freezeC) tdStyle += 'left:' + frozenLeftOffsets[c] + 'px;'; const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null; const cfStyleStr = buildCfStyle(cfResult); const isFrozenCell = c <= freezeC || r <= freezeR; const cellBg = getCellBgColor(cellData, cfStyleStr); if (cellBg) { tdStyle += 'background:' + cellBg + (isFrozenCell ? ' !important' : '') + ';'; } else if (isFrozenCell) { tdStyle += 'background:var(--color-bg) !important;'; } const styleAttr = tdStyle ? ' style="' + tdStyle + '"' : ''; let spanAttrs = ''; if (mergeInfo && !mergeInfo.hidden) { if (mergeInfo.colspan > 1) spanAttrs += ' colspan="' + mergeInfo.colspan + '"'; if (mergeInfo.rowspan > 1) spanAttrs += ' rowspan="' + mergeInfo.rowspan + '"'; } const valTitleAttr = validationMsg ? ' title="' + escapeHtml(validationMsg) + '"' : ''; tbodyHtml += ''; if (isSparklineResult(displayValue)) { tbodyHtml += '
'; } else { const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; const errInfo = getErrorInfo(String(displayValue)); const errClass = errInfo ? ' cell-error' : ''; const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; if (cellData?.img) { tbodyHtml += '
'; tbodyHtml += ''; continue; } const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); const cellContent = richHtml ?? escapeHtml(displayValue); // Data bar overlay const dbResult = dataBarStyles?.get(id); let dataBarHtml = ''; if (dbResult) { const negCls = dbResult.negative ? ' cf-data-bar--negative' : (dataBarStyles!.size > 0 && [...dataBarStyles!.values()].some(v => v.negative) ? ' cf-data-bar--positive' : ' cf-data-bar--all-positive'); dataBarHtml = '
'; } // Icon set prefix const isResult = iconSetStyles?.get(id); let iconHtml = ''; if (isResult) { iconHtml = '' + getIconChar(isResult.iconSetName, isResult.iconIndex) + ''; } tbodyHtml += '
' + dataBarHtml + iconHtml + cellContent + '
'; } if (validation && validation.type === 'list') { tbodyHtml += '
▾
'; } tbodyHtml += ''; } tbodyHtml += ''; if (rowIndicatorMap.has(r)) { const count = rowIndicatorMap.get(r); tbodyHtml += '
'; } } // --- Apply to DOM --- grid.innerHTML = headHtml + '' + tbodyHtml + ''; _isRendering = false; if (!_gridEventsAttached) { attachGridEvents(); _gridEventsAttached = true; } updateSelectionVisuals(); updateFreezeToolbarState(); renderNoteIndicators(); renderSparklines(deps); renderImageCells(); } export function renderSparklines(deps: GridRenderingDeps): void { const { grid, getCellData, computeDisplayValue } = deps; grid.querySelectorAll('canvas.sparkline-canvas').forEach((canvas: Element) => { const canvasEl = canvas as HTMLCanvasElement; const id = canvasEl.dataset.sparklineId; if (!id) return; const cellData = getCellData(id); const val = computeDisplayValue(id, cellData); if (!isSparklineResult(val)) return; const parent = canvasEl.parentElement; if (parent) { const rect = parent.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvasEl.width = rect.width * dpr; canvasEl.height = rect.height * dpr; canvasEl.style.width = rect.width + 'px'; canvasEl.style.height = rect.height + 'px'; const ctx = canvasEl.getContext('2d'); if (ctx) ctx.scale(dpr, dpr); } drawSparkline(canvasEl, val); }); } // --- Icon set character lookup --- const ICON_SETS: Record = { traffic3: ['\u{1F534}', '\u{1F7E1}', '\u{1F7E2}'], // red, yellow, green circles arrows3: ['\u2B07', '\u27A1', '\u2B06'], // down, right, up arrows stars3: ['\u2606', '\u2BEA', '\u2605'], // empty, half, full star arrows4: ['\u2B07', '\u2198', '\u2197', '\u2B06'], // down, down-right, up-right, up arrows5: ['\u2B07', '\u2198', '\u27A1', '\u2197', '\u2B06'], // down, down-right, right, up-right, up }; function getIconChar(setName: string, index: number): string { const set = ICON_SETS[setName] || ICON_SETS['traffic3']!; return set[Math.min(index, set.length - 1)] || ''; }