Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(sheets): array formula spill semantics (#91)' (#116) from feat/array-spill into main

scott 1163fb72 9911daf0

+508 -32
+17
src/css/app.css
··· 2016 2016 } 2017 2017 } 2018 2018 2019 + /* Array formula spill range */ 2020 + .sheet-grid td.spill-source, 2021 + .sheet-grid td.spill-target { 2022 + border: 1px dashed oklch(0.6 0.15 250 / 0.5); 2023 + } 2024 + .sheet-grid td.spill-target .cell-display { 2025 + color: oklch(0.55 0 0); 2026 + } 2027 + [data-theme="dark"] .sheet-grid td.spill-target .cell-display { 2028 + color: oklch(0.7 0 0); 2029 + } 2030 + @media (prefers-color-scheme: dark) { 2031 + :root:not([data-theme="light"]) .sheet-grid td.spill-target .cell-display { 2032 + color: oklch(0.7 0 0); 2033 + } 2034 + } 2035 + 2019 2036 .sheet-grid td.editing .cell-display { display: none; } 2020 2037 2021 2038 .cell-editor {
+110 -30
src/sheets/main.ts
··· 553 553 } 554 554 } 555 555 556 + // Spill range styling 557 + if (isSpillSource(id)) tdCls.push('spill-source'); 558 + if (isSpillTarget(id)) tdCls.push('spill-target'); 559 + 556 560 // Find & replace highlighting 557 561 if (findActive) { 558 562 if (isCurrentMatch(sheetsFindState, id)) tdCls.push('find-match-active'); ··· 664 668 } 665 669 666 670 function computeDisplayValue(id, cellData) { 667 - if (!cellData) return ''; 671 + if (!cellData) { 672 + // Check if this cell is a spill target 673 + const spillInfo = spillTargetMap.get(id); 674 + if (spillInfo) return formatCell(spillInfo.value, undefined); 675 + return ''; 676 + } 668 677 if (cellData.f) { 669 678 const val = evaluateFormula(cellData.f); 670 679 // Sparkline results pass through as objects for canvas rendering 671 680 if (isSparklineResult(val)) return val; 681 + // Array results: register spill and display first element 682 + if (Array.isArray(val) && (val as any)._rangeRows) { 683 + registerSpill(id, val); 684 + const spillInfo = spillMap.get(id); 685 + if (spillInfo && spillInfo.data[0] === '#SPILL!') return '#SPILL!'; 686 + return formatCell(val[0], cellData.s?.format); 687 + } 672 688 return formatCell(val, cellData.s?.format); 673 689 } 690 + // Check if this cell is a spill target (cell exists but has no formula/value) 691 + if (!cellData.v && !cellData.f) { 692 + const spillInfo = spillTargetMap.get(id); 693 + if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format); 694 + } 674 695 return formatCell(cellData.v, cellData.s?.format); 675 696 } 676 697 677 698 const evalCache = new Map(); 678 699 700 + // --- Spill tracking (display layer) --- 701 + // sourceId → { rows, cols, data: flat array } 702 + const spillMap = new Map<string, { rows: number; cols: number; data: unknown[] }>(); 703 + // targetId → { source, value } 704 + const spillTargetMap = new Map<string, { source: string; value: unknown }>(); 705 + 706 + function clearSpillMaps() { 707 + spillMap.clear(); 708 + spillTargetMap.clear(); 709 + } 710 + 711 + /** 712 + * Register a spill range from a formula that returned an array. 713 + * Populates spillMap and spillTargetMap for display. 714 + */ 715 + function registerSpill(sourceId: string, arr: unknown[]): void { 716 + const rows = (arr as any)._rangeRows || arr.length; 717 + const cols = (arr as any)._rangeCols || 1; 718 + const ref = parseRef(sourceId); 719 + if (!ref) return; 720 + 721 + spillMap.set(sourceId, { rows, cols, data: arr }); 722 + 723 + for (let r = 0; r < rows; r++) { 724 + for (let c = 0; c < cols; c++) { 725 + if (r === 0 && c === 0) continue; 726 + const targetId = colToLetter(ref.col + c) + (ref.row + r); 727 + const idx = r * cols + c; 728 + // Check for collision: target has real data 729 + const targetData = getCellData(targetId); 730 + if (targetData && (targetData.f || (targetData.v !== '' && targetData.v !== undefined && targetData.v !== null))) { 731 + // Collision — mark source as #SPILL! 732 + spillMap.set(sourceId, { rows: 0, cols: 0, data: ['#SPILL!'] }); 733 + // Clear any targets already registered for this source 734 + for (const [tid, info] of spillTargetMap) { 735 + if (info.source === sourceId) spillTargetMap.delete(tid); 736 + } 737 + return; 738 + } 739 + spillTargetMap.set(targetId, { source: sourceId, value: arr[idx] ?? '' }); 740 + } 741 + } 742 + } 743 + 744 + /** 745 + * Check if a cell is a spill source (has spilled array results). 746 + */ 747 + function isSpillSource(cellId: string): boolean { 748 + const info = spillMap.get(cellId); 749 + return !!info && info.rows > 0; 750 + } 751 + 752 + /** 753 + * Check if a cell is a spill target. 754 + */ 755 + function isSpillTarget(cellId: string): boolean { 756 + return spillTargetMap.has(cellId); 757 + } 758 + 679 759 // --- Recalc engine integration --- 680 760 function buildRecalcCellStore() { 681 761 return { ··· 1399 1479 } 1400 1480 if (td) td.classList.remove('editing'); 1401 1481 editingCell = null; 1402 - evalCache.clear(); invalidateRecalcEngine(); 1482 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1403 1483 clearGridHighlights(); 1404 1484 hideTooltip(); 1405 1485 refreshVisibleCells(); ··· 1510 1590 // Undo: Cmd+Z (Mac) / Ctrl+Z 1511 1591 if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 1512 1592 e.preventDefault(); 1513 - if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1593 + if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1514 1594 } 1515 1595 // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) 1516 1596 if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { 1517 1597 e.preventDefault(); 1518 - if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); } 1598 + if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1519 1599 } 1520 1600 // Hide rows: Cmd+9 1521 1601 if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); hideSelectedRows(); } ··· 1672 1752 } 1673 1753 } 1674 1754 }); 1675 - evalCache.clear(); invalidateRecalcEngine(); 1755 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1676 1756 refreshVisibleCells(); 1677 1757 } 1678 1758 ··· 1756 1836 } 1757 1837 } 1758 1838 }); 1759 - evalCache.clear(); invalidateRecalcEngine(); 1839 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1760 1840 refreshVisibleCells(); 1761 1841 } 1762 1842 ··· 2118 2198 setCellData(id, { v: value, f: '' }); 2119 2199 } 2120 2200 2121 - evalCache.clear(); invalidateRecalcEngine(); 2201 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2122 2202 refreshVisibleCells(); 2123 2203 } 2124 2204 ··· 2231 2311 } 2232 2312 } 2233 2313 document.getElementById('tb-undo').addEventListener('click', () => { 2234 - if (undoManager) { undoManager.undo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2314 + if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2235 2315 }); 2236 2316 document.getElementById('tb-redo').addEventListener('click', () => { 2237 - if (undoManager) { undoManager.redo(); evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2317 + if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2238 2318 }); 2239 2319 // Update undo/redo state whenever stacks change 2240 2320 if (undoManager) { ··· 2466 2546 } 2467 2547 }); 2468 2548 }); 2469 - evalCache.clear(); invalidateRecalcEngine(); 2549 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2470 2550 refreshVisibleCells(); 2471 2551 } 2472 2552 ··· 2502 2582 rowColInsertRow(getCells, setCellData, rowIndex, colCount); 2503 2583 }); 2504 2584 sheet.set('rowCount', (sheet.get('rowCount') || DEFAULT_ROWS) + 1); 2505 - evalCache.clear(); invalidateRecalcEngine(); 2585 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2506 2586 renderGrid(); 2507 2587 } 2508 2588 ··· 2515 2595 rowColDeleteRow(getCells, setCellData, rowIndex, colCount); 2516 2596 }); 2517 2597 sheet.set('rowCount', rowCount - 1); 2518 - evalCache.clear(); invalidateRecalcEngine(); 2598 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2519 2599 renderGrid(); 2520 2600 } 2521 2601 ··· 2526 2606 rowColInsertColumn(getCells, setCellData, colIndex, rowCount); 2527 2607 }); 2528 2608 sheet.set('colCount', (sheet.get('colCount') || DEFAULT_COLS) + 1); 2529 - evalCache.clear(); invalidateRecalcEngine(); 2609 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2530 2610 renderGrid(); 2531 2611 } 2532 2612 ··· 2539 2619 rowColDeleteColumn(getCells, setCellData, colIndex, rowCount); 2540 2620 }); 2541 2621 sheet.set('colCount', colCount - 1); 2542 - evalCache.clear(); invalidateRecalcEngine(); 2622 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2543 2623 renderGrid(); 2544 2624 } 2545 2625 ··· 2693 2773 const newActive = deleteSheet(ydoc, ySheets, sheetIdx, activeSheetIdx); 2694 2774 if (newActive >= 0) { 2695 2775 activeSheetIdx = newActive; 2696 - evalCache.clear(); 2776 + evalCache.clear(); clearSpillMaps(); 2697 2777 invalidateRecalcEngine(); 2698 2778 renderSheetTabs(); 2699 2779 renderGrid(); ··· 2707 2787 const newSheet = duplicateSheet(ydoc, ySheets, sheetIdx, targetIdx); 2708 2788 if (newSheet) { 2709 2789 activeSheetIdx = targetIdx; 2710 - evalCache.clear(); 2790 + evalCache.clear(); clearSpillMaps(); 2711 2791 invalidateRecalcEngine(); 2712 2792 renderSheetTabs(); 2713 2793 renderGrid(); ··· 2777 2857 tab.appendChild(label); 2778 2858 2779 2859 tab.draggable = true; 2780 - tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); invalidateRecalcEngine(); renderGrid(); }); 2860 + tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); }); 2781 2861 2782 2862 // Double-click for inline rename 2783 2863 tab.addEventListener('dblclick', (e) => { ··· 2866 2946 activeSheetIdx++; 2867 2947 } 2868 2948 2869 - evalCache.clear(); 2949 + evalCache.clear(); clearSpillMaps(); 2870 2950 invalidateRecalcEngine(); 2871 2951 renderSheetTabs(); 2872 2952 renderGrid(); ··· 2948 3028 document.getElementById('add-sheet').addEventListener('click', () => { 2949 3029 let count = 0; 2950 3030 ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 2951 - ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); invalidateRecalcEngine(); renderGrid(); 3031 + ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 2952 3032 }); 2953 3033 2954 3034 // --- Document title --- ··· 2990 3070 statusText.textContent = 'Synced'; 2991 3071 // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array 2992 3072 // objects that were observed during initial setup (before data loaded from peers) 2993 - getCells().observeDeep(() => { evalCache.clear(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3073 + getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 2994 3074 ySheets.observe(() => { renderSheetTabs(); }); 2995 3075 ySheets.observeDeep((events) => { 2996 3076 for (const event of events) { ··· 3002 3082 } 3003 3083 } 3004 3084 if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 3005 - evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3085 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3006 3086 } 3007 3087 } 3008 3088 }); ··· 3162 3242 if (neededRows > (sheet.get('rowCount') || DEFAULT_ROWS)) sheet.set('rowCount', neededRows); 3163 3243 if (neededCols > (sheet.get('colCount') || DEFAULT_COLS)) sheet.set('colCount', neededCols); 3164 3244 }); 3165 - evalCache.clear(); invalidateRecalcEngine(); renderGrid(); 3245 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3166 3246 3167 3247 if (hasHeaders) { 3168 3248 ydoc.transact(() => { ··· 3352 3432 }); 3353 3433 3354 3434 // --- React to Yjs changes --- 3355 - getCells().observeDeep(() => { evalCache.clear(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3435 + getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3356 3436 ySheets.observe(() => { renderSheetTabs(); }); 3357 3437 3358 3438 // Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change from remote collaborators ··· 3367 3447 } 3368 3448 // CF rules or validations changed 3369 3449 if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 3370 - evalCache.clear(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3450 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3371 3451 } 3372 3452 } 3373 3453 }); ··· 3956 4036 }); 3957 4037 }); 3958 4038 3959 - evalCache.clear(); invalidateRecalcEngine(); 4039 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 3960 4040 refreshVisibleCells(); 3961 4041 overlay.remove(); 3962 4042 }); ··· 4126 4206 const yArr = getCfRules(); 4127 4207 ydoc.transact(() => { yArr.delete(idx, 1); }); 4128 4208 renderCfModal(); 4129 - evalCache.clear(); invalidateRecalcEngine(); 4209 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4130 4210 refreshVisibleCells(); 4131 4211 }); 4132 4212 }); ··· 4144 4224 const yArr = getCfRules(); 4145 4225 ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 4146 4226 renderCfModal(); 4147 - evalCache.clear(); invalidateRecalcEngine(); 4227 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4148 4228 refreshVisibleCells(); 4149 4229 }); 4150 4230 ··· 4309 4389 const numVal = Number(item); 4310 4390 const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item); 4311 4391 setCellData(cellIdStr, { v: value, f: '' }); 4312 - evalCache.clear(); invalidateRecalcEngine(); 4392 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4313 4393 refreshVisibleCells(); 4314 4394 dropdown.remove(); 4315 4395 }); ··· 5112 5192 const numVal = Number(result.newValue); 5113 5193 const value = result.newValue === '' ? '' : (!isNaN(numVal) && result.newValue !== '' ? numVal : result.newValue); 5114 5194 setCellData(result.cellId, { v: value, f: '' }); 5115 - evalCache.clear(); invalidateRecalcEngine(); 5195 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 5116 5196 runSheetsFind(); // re-search after replace 5117 5197 } 5118 5198 }); ··· 5128 5208 setCellData(r.cellId, { v: value, f: '' }); 5129 5209 } 5130 5210 }); 5131 - evalCache.clear(); invalidateRecalcEngine(); 5211 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 5132 5212 showToast('Replaced ' + results.length + ' match' + (results.length > 1 ? 'es' : '')); 5133 5213 runSheetsFind(); 5134 5214 }
+142 -2
src/sheets/recalc.ts
··· 12 12 * This module is DOM-free and operates on an abstract cell store interface. 13 13 */ 14 14 15 - import { extractRefs, evaluate, parseRef, colToLetter } from './formulas.js'; 16 - import type { CellStore, RecalcOptions, CellValue, NamedRangesMap } from './types.js'; 15 + import { extractRefs, evaluate, parseRef, colToLetter, letterToCol } from './formulas.js'; 16 + import type { CellStore, RecalcOptions, CellValue, NamedRangesMap, RangeArray } from './types.js'; 17 17 18 18 // --- Volatile functions --- 19 19 ··· 41 41 volatileCells: Set<string>; 42 42 _cyclePaths: string[][]; 43 43 44 + // Spill tracking: source cell → list of spilled target cells 45 + _spillRanges: Map<string, string[]>; 46 + // Reverse map: spill target cell → source cell 47 + _spillTargets: Map<string, string>; 48 + 44 49 constructor(store: CellStore, options: RecalcOptions = {}) { 45 50 this.store = store; 46 51 this.options = options; ··· 56 61 57 62 // Cycle information from the last recalculation 58 63 this._cyclePaths = []; 64 + 65 + // Spill tracking 66 + this._spillRanges = new Map(); 67 + this._spillTargets = new Map(); 59 68 } 60 69 61 70 /** ··· 113 122 */ 114 123 getCyclePaths(): string[][] { 115 124 return this._cyclePaths; 125 + } 126 + 127 + /** 128 + * Get the source cell for a spill target cell. 129 + * Returns null if the cell is not a spill target. 130 + */ 131 + getSpillSource(cellId: string): string | null { 132 + return this._spillTargets.get(cellId) || null; 133 + } 134 + 135 + /** 136 + * Get the list of spill target cells for a source formula cell. 137 + * Returns empty array if the cell has no spill range. 138 + */ 139 + getSpillRange(cellId: string): string[] { 140 + return this._spillRanges.get(cellId) || []; 116 141 } 117 142 118 143 /** ··· 399 424 400 425 /** 401 426 * Evaluate a single cell's formula and update its value in the store. 427 + * Handles array results by spilling into adjacent cells. 402 428 * @param {string} cellId 403 429 * @param {Set<string>} changed - Accumulator for cells whose values changed 404 430 */ ··· 427 453 this.options.namedRanges || null, 428 454 ); 429 455 456 + // Handle array results (spill semantics) 457 + if (Array.isArray(result) && (result as RangeArray)._rangeRows) { 458 + this._handleSpill(cellId, result as RangeArray, changed); 459 + return; 460 + } 461 + 462 + // Clear any previous spill range for this cell 463 + this._clearSpill(cellId, changed); 464 + 430 465 cell.v = result; 431 466 this.store.set(cellId, cell); 432 467 ··· 434 469 if (!Object.is(oldVal, result) && !(oldVal === '' && result === '') && String(oldVal) !== String(result)) { 435 470 changed.add(cellId); 436 471 } 472 + } 473 + 474 + /** 475 + * Clear spill targets from a previous spill range for a source cell. 476 + */ 477 + _clearSpill(sourceId: string, changed: Set<string>): void { 478 + const oldTargets = this._spillRanges.get(sourceId); 479 + if (!oldTargets) return; 480 + 481 + for (const targetId of oldTargets) { 482 + this._spillTargets.delete(targetId); 483 + const targetCell = this.store.get(targetId); 484 + if (targetCell && !targetCell.f) { 485 + const oldV = targetCell.v; 486 + targetCell.v = ''; 487 + this.store.set(targetId, targetCell); 488 + if (oldV !== '') changed.add(targetId); 489 + } 490 + } 491 + this._spillRanges.delete(sourceId); 492 + } 493 + 494 + /** 495 + * Handle an array formula result by spilling values into adjacent cells. 496 + * Sets #SPILL! on the source cell if any target cell is occupied. 497 + */ 498 + _handleSpill(sourceId: string, arr: RangeArray, changed: Set<string>): void { 499 + const rows = arr._rangeRows || arr.length; 500 + const cols = arr._rangeCols || 1; 501 + const sourceRef = parseRef(sourceId); 502 + if (!sourceRef) return; 503 + 504 + // Compute target cell IDs (excluding the source cell itself) 505 + const targets: string[] = []; 506 + for (let r = 0; r < rows; r++) { 507 + for (let c = 0; c < cols; c++) { 508 + if (r === 0 && c === 0) continue; // source cell 509 + const targetId = colToLetter(sourceRef.col + c) + (sourceRef.row + r); 510 + targets.push(targetId); 511 + } 512 + } 513 + 514 + // Check for collisions: target cell has content and is not a spill target from this source 515 + const oldTargets = new Set(this._spillRanges.get(sourceId) || []); 516 + for (const targetId of targets) { 517 + // Skip cells that are our own previous spill targets 518 + if (oldTargets.has(targetId)) continue; 519 + // Skip cells that are our own current spill targets (from _spillTargets pointing to us) 520 + if (this._spillTargets.get(targetId) === sourceId) continue; 521 + 522 + const targetCell = this.store.get(targetId); 523 + if (targetCell && (targetCell.f || (targetCell.v !== '' && targetCell.v !== undefined))) { 524 + // Collision — set #SPILL! and don't spill 525 + this._clearSpill(sourceId, changed); 526 + const cell = this.store.get(sourceId); 527 + if (cell) { 528 + const oldV = cell.v; 529 + cell.v = '#SPILL!'; 530 + this.store.set(sourceId, cell); 531 + if (oldV !== '#SPILL!') changed.add(sourceId); 532 + } 533 + return; 534 + } 535 + } 536 + 537 + // Clear any old spill targets that are no longer needed 538 + this._clearSpill(sourceId, changed); 539 + 540 + // Write source cell value (first element) 541 + const cell = this.store.get(sourceId); 542 + if (cell) { 543 + const oldV = cell.v; 544 + cell.v = arr[0] ?? ''; 545 + this.store.set(sourceId, cell); 546 + if (!Object.is(oldV, cell.v) && String(oldV) !== String(cell.v)) { 547 + changed.add(sourceId); 548 + } 549 + } 550 + 551 + // Write spill target values 552 + const newTargets: string[] = []; 553 + for (let r = 0; r < rows; r++) { 554 + for (let c = 0; c < cols; c++) { 555 + if (r === 0 && c === 0) continue; 556 + const targetId = colToLetter(sourceRef.col + c) + (sourceRef.row + r); 557 + const idx = r * cols + c; 558 + const value = arr[idx] ?? ''; 559 + 560 + // Ensure the target cell exists in the store 561 + let targetCell = this.store.get(targetId); 562 + if (!targetCell) { 563 + targetCell = { v: '', f: '' }; 564 + } 565 + const oldV = targetCell.v; 566 + targetCell.v = value; 567 + this.store.set(targetId, targetCell); 568 + if (!Object.is(oldV, value) && String(oldV) !== String(value)) { 569 + changed.add(targetId); 570 + } 571 + 572 + newTargets.push(targetId); 573 + this._spillTargets.set(targetId, sourceId); 574 + } 575 + } 576 + this._spillRanges.set(sourceId, newTargets); 437 577 } 438 578 439 579 /**
+239
tests/spill.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { RecalcEngine } from '../src/sheets/recalc.js'; 3 + import { colToLetter } from '../src/sheets/formulas.js'; 4 + import type { CellStore, RecalcCellData } from '../src/sheets/types.js'; 5 + 6 + // Helper: create a simple Map-based CellStore 7 + function createStore(data: Record<string, { v?: unknown; f?: string }>): CellStore { 8 + const map = new Map<string, RecalcCellData>(); 9 + for (const [id, d] of Object.entries(data)) { 10 + map.set(id, { v: d.v ?? '', f: d.f ?? '' }); 11 + } 12 + return { 13 + get: (id: string) => map.get(id) || null, 14 + set: (id: string, cell: RecalcCellData) => { map.set(id, cell); }, 15 + has: (id: string) => map.has(id), 16 + entries: () => map.entries(), 17 + getAllFormulaCells: () => [...map.entries()].filter(([, c]) => !!c.f), 18 + }; 19 + } 20 + 21 + // Helper: get value from store 22 + function val(store: CellStore, id: string): unknown { 23 + const cell = store.get(id); 24 + return cell ? cell.v : undefined; 25 + } 26 + 27 + describe('Array Spill', () => { 28 + describe('basic spill down', () => { 29 + it('spills FILTER results into cells below', () => { 30 + // A1:A3 = [10, 20, 30], B1:B3 = [true, false, true] 31 + // D1 = FILTER(A1:A3, B1:B3) → should spill [10, 30] into D1, D2 32 + const store = createStore({ 33 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 34 + B1: { v: true }, B2: { v: false }, B3: { v: true }, 35 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 36 + }); 37 + const engine = new RecalcEngine(store); 38 + engine.buildFullGraph(); 39 + engine.recalculate('D1'); 40 + 41 + expect(val(store, 'D1')).toBe(10); 42 + expect(val(store, 'D2')).toBe(30); 43 + }); 44 + 45 + it('spills SORT results vertically', () => { 46 + // A1:A3 = [30, 10, 20] 47 + // C1 = SORT(A1:A3, 1, 1) → should spill [10, 20, 30] into C1, C2, C3 48 + const store = createStore({ 49 + A1: { v: 30 }, A2: { v: 10 }, A3: { v: 20 }, 50 + C1: { f: 'SORT(A1:A3, 1, 1)' }, 51 + }); 52 + const engine = new RecalcEngine(store); 53 + engine.buildFullGraph(); 54 + engine.recalculate('C1'); 55 + 56 + expect(val(store, 'C1')).toBe(10); 57 + expect(val(store, 'C2')).toBe(20); 58 + expect(val(store, 'C3')).toBe(30); 59 + }); 60 + }); 61 + 62 + describe('multi-column spill', () => { 63 + it('spills 2D array results into rows and columns', () => { 64 + // A1:B2 = [[1, 2], [3, 4]], C1:C2 = [true, true] 65 + // D1 = FILTER(A1:B2, C1:C2) → should spill 2x2 into D1,E1,D2,E2 66 + const store = createStore({ 67 + A1: { v: 1 }, B1: { v: 2 }, 68 + A2: { v: 3 }, B2: { v: 4 }, 69 + C1: { v: true }, C2: { v: true }, 70 + D1: { f: 'FILTER(A1:B2, C1:C2)' }, 71 + }); 72 + const engine = new RecalcEngine(store); 73 + engine.buildFullGraph(); 74 + engine.recalculate('D1'); 75 + 76 + expect(val(store, 'D1')).toBe(1); 77 + expect(val(store, 'E1')).toBe(2); 78 + expect(val(store, 'D2')).toBe(3); 79 + expect(val(store, 'E2')).toBe(4); 80 + }); 81 + }); 82 + 83 + describe('#SPILL! error', () => { 84 + it('shows #SPILL! when target cell is occupied', () => { 85 + // A1:A3 = [10, 20, 30], B1:B3 = [true, true, true] 86 + // D1 = FILTER(A1:A3, B1:B3) → wants to spill to D1, D2, D3 87 + // D2 already has content → #SPILL! 88 + const store = createStore({ 89 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 90 + B1: { v: true }, B2: { v: true }, B3: { v: true }, 91 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 92 + D2: { v: 'occupied' }, 93 + }); 94 + const engine = new RecalcEngine(store); 95 + engine.buildFullGraph(); 96 + engine.recalculate('D1'); 97 + 98 + expect(val(store, 'D1')).toBe('#SPILL!'); 99 + // D2 should still have its original value 100 + expect(val(store, 'D2')).toBe('occupied'); 101 + }); 102 + 103 + it('does NOT #SPILL! when target cell is a previous spill from same source', () => { 104 + // First evaluation spills, second should be able to overwrite its own spill targets 105 + const store = createStore({ 106 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 107 + B1: { v: true }, B2: { v: true }, B3: { v: true }, 108 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 109 + }); 110 + const engine = new RecalcEngine(store); 111 + engine.buildFullGraph(); 112 + 113 + // First calc 114 + engine.recalculate('D1'); 115 + expect(val(store, 'D1')).toBe(10); 116 + expect(val(store, 'D2')).toBe(20); 117 + expect(val(store, 'D3')).toBe(30); 118 + 119 + // Recalculate — should not #SPILL! on its own targets 120 + const changed = engine.recalculate('D1'); 121 + expect(val(store, 'D1')).toBe(10); 122 + expect(val(store, 'D2')).toBe(20); 123 + expect(val(store, 'D3')).toBe(30); 124 + }); 125 + }); 126 + 127 + describe('spill cleanup', () => { 128 + it('clears old spill targets when formula changes to scalar', () => { 129 + const store = createStore({ 130 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 131 + B1: { v: true }, B2: { v: true }, B3: { v: true }, 132 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 133 + }); 134 + const engine = new RecalcEngine(store); 135 + engine.buildFullGraph(); 136 + 137 + // First: spill into D1, D2, D3 138 + engine.recalculate('D1'); 139 + expect(val(store, 'D2')).toBe(20); 140 + expect(val(store, 'D3')).toBe(30); 141 + 142 + // Change formula to scalar 143 + store.set('D1', { v: '', f: '1+1' }); 144 + engine.updateCell('D1'); 145 + engine.recalculate('D1'); 146 + 147 + expect(val(store, 'D1')).toBe(2); 148 + // Old spill targets should be cleared 149 + expect(val(store, 'D2')).toBe(''); 150 + expect(val(store, 'D3')).toBe(''); 151 + }); 152 + 153 + it('clears old spill targets when spill range shrinks', () => { 154 + const store = createStore({ 155 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 156 + B1: { v: true }, B2: { v: true }, B3: { v: true }, 157 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 158 + }); 159 + const engine = new RecalcEngine(store); 160 + engine.buildFullGraph(); 161 + 162 + // Spill all 3 163 + engine.recalculate('D1'); 164 + expect(val(store, 'D3')).toBe(30); 165 + 166 + // Now change B2 to false → only 2 results 167 + store.set('B2', { v: false, f: '' }); 168 + engine.recalculate('B2'); 169 + 170 + expect(val(store, 'D1')).toBe(10); 171 + expect(val(store, 'D2')).toBe(30); 172 + // D3 was cleared 173 + expect(val(store, 'D3')).toBe(''); 174 + }); 175 + }); 176 + 177 + describe('spill tracking', () => { 178 + it('getSpillSource returns the source cell for a spill target', () => { 179 + const store = createStore({ 180 + A1: { v: 10 }, A2: { v: 20 }, 181 + B1: { v: true }, B2: { v: true }, 182 + D1: { f: 'FILTER(A1:A2, B1:B2)' }, 183 + }); 184 + const engine = new RecalcEngine(store); 185 + engine.buildFullGraph(); 186 + engine.recalculate('D1'); 187 + 188 + expect(engine.getSpillSource('D2')).toBe('D1'); 189 + expect(engine.getSpillSource('D1')).toBeNull(); 190 + expect(engine.getSpillSource('A1')).toBeNull(); 191 + }); 192 + 193 + it('getSpillRange returns all cells in the spill range', () => { 194 + const store = createStore({ 195 + A1: { v: 10 }, A2: { v: 20 }, A3: { v: 30 }, 196 + B1: { v: true }, B2: { v: true }, B3: { v: true }, 197 + D1: { f: 'FILTER(A1:A3, B1:B3)' }, 198 + }); 199 + const engine = new RecalcEngine(store); 200 + engine.buildFullGraph(); 201 + engine.recalculate('D1'); 202 + 203 + const range = engine.getSpillRange('D1'); 204 + expect(range).toEqual(['D2', 'D3']); 205 + }); 206 + }); 207 + 208 + describe('scalar formulas unchanged', () => { 209 + it('scalar formula results do not spill', () => { 210 + const store = createStore({ 211 + A1: { v: 5 }, 212 + B1: { f: 'A1*2' }, 213 + }); 214 + const engine = new RecalcEngine(store); 215 + engine.buildFullGraph(); 216 + engine.recalculate('B1'); 217 + 218 + expect(val(store, 'B1')).toBe(10); 219 + expect(engine.getSpillRange('B1')).toEqual([]); 220 + }); 221 + }); 222 + 223 + describe('UNIQUE spill', () => { 224 + it('spills UNIQUE results', () => { 225 + const store = createStore({ 226 + A1: { v: 'apple' }, A2: { v: 'banana' }, A3: { v: 'apple' }, A4: { v: 'cherry' }, 227 + B1: { v: true }, B2: { v: true }, B3: { v: true }, B4: { v: true }, 228 + D1: { f: 'UNIQUE(A1:A4)' }, 229 + }); 230 + const engine = new RecalcEngine(store); 231 + engine.buildFullGraph(); 232 + engine.recalculate('D1'); 233 + 234 + expect(val(store, 'D1')).toBe('apple'); 235 + expect(val(store, 'D2')).toBe('banana'); 236 + expect(val(store, 'D3')).toBe('cherry'); 237 + }); 238 + }); 239 + });