How to merge cells with svelte-window

By Michael Lucht
Keywords: Svelte, Virtualized, svelte-window, cell-merging, data-visualization
March 3, 2021

Introduction

svelte-window is an attempt to an 1:1 port of react-window, but there are small differences. Concerning cell-merging, that is a good thing. There are discussions about it, but bottom line is that cell-merging with react-window is not easy. With svelte-window it is not a big deal.

The difference between the libraries is that react-window directly renders its children, while svelte-window only hands the necessary data as an array to its slots. The Svelte approach implies that the consumer of the library needs to implement a little more herself, but it also means that some of the control is shifted.

With cell-merging I mean that you can form combine cells in a grid into bigger blocks, spanning over several rows and columns. Only rectangular blocks are covered. The most efficient implementation can depend on the type of merging you want to have in your grid. I only show some examples, there are many more ways to achieve cell-merging.

a merge roads sign

Basics

Let's assume, we got a completely filled grid, where every cell is uniquely identified by the row and column index, and every combination of row and column index maps to exactly one cell. Eg. If we want to merge the cell in row 4 and column 8 with the cell 5 in column 8, we could increase the height of cell (4,8) by the height of row 5 and skip the rendering of cell (5,8). If we additionally want to merge the cells of the next column (4,9) and (5,9), we would skip to render these cells and increase the width of the cell (4,8) by the width of column 9. With a simple table this is good practise, eg. see this 3x3 table implementation, where the cells in the second and third row and column are merged:

<table> <tr> <td>1-1</td> <td>1-2</td> <td>1-3</td> </tr><tr> <td>2-1</td> <td rowspan="2" colspan="2">2-2</td> <td style="display:none">2-3</td> </tr><tr> <td>3-1</td> <td style="display:none">3-2</td> <td style="display:none">3-3</td> </tr> </table>

The result looks like this:

1-1 1-2 1-3
2-1 2-2
3-1

It gets more complicated when we introduce windowing. It means, that we have a big table, and only those cells are supposed to be rendered, which are currently within the users' view. So, if we have merged the cells (4, 8) to (5, 9), but the top-left cell of our window is (5, 8), only the lower part of the merged cell is in view. The first question is, what do we want to render? There are three options:

1 render the whole cell 1 render only the lower half 1 skip the whole cell

To render the whole merged cell, we would need to acquire the height of row 4. svelte-window hands us an array of item-data starting in the cell ( 5, 8), so the information would not be present in that array. With a fixed-height table, that would not be a problem, since we already know the height. It would be more cumbersome with flexible height.

Option 3 is the simplest, but it may look glitchy. When you don't have big merged areas and experiment with the props overscanColumnCount and overscanRowCount, it might even work. These props tell svelte-window to render a few rows and columns outside of the visible range.

My examples will focus on option 2. It only deals with the item array from svelte-window and it shifts the text or cell content into the visible area.

Implemented cell-merging

Here I will show two examples, of how to implement cell-merging. The first extends the basic use-case of svelte-window, while the second approach html tables, which is a bit unconventional, at least in a react-window-ish context, since something like this is not easily possible with it.

Example 1

An important part of cell-merging is how you structure your data. In the example, there is a generic rule for merging, but in real-world data, it would probably be not so hard to programmatically assign the required attributes. The merging itself is quick because the array of visible cells is rather small.

Data preparation - Grid

The goal of this example is to merge 2 rows and 3 cells in every sevenths row and column. The table has 1000 rows and 1000 columns. Here is the (somewhat ugly) data creation step:

const data = new Array(1000) .fill(true) .map((v,i)=>new Array(1000) .fill(true) .map((v,j)=>{ if ((i % 7) === 0){ if ((j % 7) === 0){ return {color:"red",mergeRow:2,mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeRow:2,mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red",mergeRow:2} } }else if ((i % 7) === 1){ if ((j % 7) === 0){ return {color:"red",mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red"} } } return {color:"blue"} }))

So, eg. the cell in row 7, column 7 is merged up to cell (8, 9), so the data item for that cell gets attributes mergeRow:2 and mergeCol:3. Cell merging is only looking downward and to the right, so cell (7, 8) also gets the attribute mergeRow:2, but only mergeCol:2. The cell (8, 7) does not get the mergeRow attribute, which corresponds to mergeRow:1 (~every cell is merged with itself), but also mergeCol:3 since it is merged with the two cells to its right. All merged cells get the same content (color: "red").

Grid implementation

Here is the grid code:

<Grid columnCount={1000} columnWidth={(index) => columnWidths[index]} height={400} rowCount={1000} rowHeight={(index) => rowHeights[index]} width={400} overscanRowCount={0} overscanColumnCount={0} let:items> {#each mergeCells(items) as it (it.key)} <div style={sty(it.style) + "background-color:"+it.color}> {it.text} </div> {/each} </Grid>

In this case I use the variable size grid. The only important deviation from the default library examples is that a function mergeCells is wrapped around the items array in the #each command. Let`s look at the implementation:

const mergeCells = (items) => { // create a new array, which will be rendered const retitems = []; // object, to save, which cells can be skipped const skip = {}; for (let i = 0; i < items.length; i++) { // retain only a copy of the original item // especially copy the style object, since we are going to mutate it const item = {...items[i],style:{...items[i].style}}; let old; if (old=skip[item.rowIndex+"-"+item.columnIndex]){ // cell can be skipped. if (old.rowIndex < item.rowIndex){ old.rowIndex = item.rowIndex; old.style.height += item.style.height; } if (old.columnIndex < item.columnIndex){ old.columnIndex = item.columnIndex; old.style.width += item.style.width; } continue } // process cell const dataItem = data[item.rowIndex][item.columnIndex]; item.color = dataItem.color; item.text = item.rowIndex + "-" + item.columnIndex; retitems.push(item); // check if other cells can be skipped if ((dataItem.mergeCol||1) === 1 && (dataItem.mergeRow||1)===1){ continue; } for (let m = 0; m < (dataItem.mergeRow||1);m++){ for (let k = 0; k < (dataItem.mergeCol||1); k++){ // found a cell which can be skipped skip[(item.rowIndex + m) + "-" + (item.columnIndex+k)] = item; } } } return retitems; };

Compared to our 1000x1000 input array, the items array is small, so looping through it is not expensive with regard to performance. The array items always starts in the top left corner and then moves row-wise. In the loop, the code processes the cell and if the corresponding data item has either attribute mergeRow or mergeCol larger than one, it adds the following row and/or column indices as key to the skip object, the value is the processed data item itself. When one of the cells, which is supposed to be skipped is reached, it finds its index in the skip object. It then checks if the row height or column width should be added to the rendered cell since it should only be added once.

<script lang="ts"> import { VariableSizeGrid as Grid, styleString as sty, } from "svelte-window"; const rowHeights = new Array(1000) .fill(true) .map(() => 25 + Math.round(Math.random() * 50)); const columnWidths = new Array(1000) .fill(true) .map(() => 75 + Math.round(Math.random() * 50)); const data = new Array(1000) .fill(true) .map((v,i)=>new Array(1000) .fill(true) .map((v,j)=>{ if ((i % 7) === 0){ if ((j % 7) === 0){ return {color:"red",mergeRow:2,mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeRow:2,mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red",mergeRow:2} } }else if ((i % 7) === 1){ if ((j % 7) === 0){ return {color:"red",mergeCol:3} }else if ((j % 7 ) === 1){ return {color:"red",mergeCol:2} }else if ((j % 7 ) === 2){ return {color:"red"} } } return {color:"blue"} })) const mergeCells = (items : GridChildComponentProps[]) => { // create a new array, which will be rendered const retitems : (GridChildComponentProps & {color?:string,text?:string})[] = []; // object, to save, which cells can be skipped const skip = {}; for (let i = 0; i < items.length; i++) { // retain only a copy of the original item // especially copy the style object, since we are going to mutate it const item : GridChildComponentProps & {color?:string,text?:string} = {...items[i],style:{...items[i].style}}; let old; if (old=skip[item.rowIndex+"-"+item.columnIndex]){ // cell can be skipped. if (old.rowIndex < item.rowIndex){ old.rowIndex = item.rowIndex; old.style.height += item.style.height; } if (old.columnIndex < item.columnIndex){ old.columnIndex = item.columnIndex; old.style.width += item.style.width; } continue } // process cell const dataItem = data[item.rowIndex][item.columnIndex]; item.color = dataItem.color; item.text = item.rowIndex + "-" + item.columnIndex; retitems.push(item); // check if other cells can be skipped if ((dataItem.mergeCol||1) === 1 && (dataItem.mergeRow||1)===1){ continue; } for (let m = 0; m < (dataItem.mergeRow||1);m++){ for (let k = 0; k < (dataItem.mergeCol||1); k++){ // found a cell which can be skipped skip[(item.rowIndex + m) + "-" + (item.columnIndex+k)] = item; } } } return retitems; }; </script> <Grid columnCount={1000} columnWidth={(index) => columnWidths[index]} height={600} rowCount={1000} rowHeight={(index) => rowHeights[index]} width={1200} overscanRowCount={0} overscanColumnCount={0} let:items> {#each mergeCells(items) as it (it.key)} <div style={sty(it.style) + "background-color:"+it.color}> {it.text} </div> {/each} </Grid>

So, if the cell (7, 7) is somewhere in the middle of the viewable range, it is rendered as a 2x3 sized cell. If it is near the right border, eg. only column 8 is visible, the width of column 9 is not added to it, so it is rendered as a 2x2 sized cell. If it is outside of the visible range, eg. the visible range starts at row 8, then the cell (8, 7) is rendered as a 1x3 sized cell.

Example 2

The second example I want to show here involves an html table. It might happen that you got a normal table, which becomes too large over time, so you want to add windowing. Usually, windowed tables with libraries like react-window and react-virtualized use divs to create grids, which can lead to inconveniences, since these are styled a bit differently than html tables. Since you got more control over the rendering with svelte-window you could migrate your table to something like I will show here. The final table shows, at least to me, surprisingly good performance

Let's start with the grid, I use a fixed size grid for simplicity here. I move out the table code to a separate file, instead of inlining everything. Mainly just the items array is passed as a prob:

<Grid columnCount={1000} columnWidth={160} height={600} rowCount={1000} rowHeight={80} width={1200} let:items> <BigTable items={items} rowHeight={80}/> </Grid>

The html code in BigTable.svelte is a normal html table:

<table style={'table-layout: fixed;position: absolute;top: 0;left: 0;width:' + tablewidth + 'px;transform:translate(' + left + 'px,' + top + 'px);'}> {#each rows as _, r} <tr style={`height:${rowHeight}px;`}> {#each cols as __, c} <td {...spans(r, c)} > {isScrolling ? '...' : r + rowmin + ' - ' + (c + colmin)} </td> {/each} </tr> {/each} </table>

The table is moved around, it is always pushed to the position of the top-left cell in the items array. I am using transform: translate instead of setting top and left because it should deliver better performance. All cell merging is handled with the function spans and I will show it down below. From the arrays rows and cols, I only use the index, the value itself is not interesting. Here is how the values are generated within the <script>-section:

export let items = []; export let rowHeight; $: ({ top, left } = items[0].style); $: [rowmin, colmin] = [items[0].rowIndex, items[0].columnIndex]; $: [[rowmax, colmax, tablewidth]] = items .slice(-1) .map((v) => [ v.rowIndex, v.columnIndex, v.style.left + v.style.width - left, v.style.top + v.style.height - top, ]); $: (nrows = rowmax - rowmin + 1); $: (ncols = colmax - colmin + 1); $: (isScrolling = items[0].isScrolling); $: (rows = Array(nrows).fill(true)); $: (cols = Array(ncols).fill(true));

top, left, rowmin and colmin are taken from the first element in the array items and rowmax, colmax and tablewidth are inferred from the last item.

Data preparation - Table

The data structuring from the first and this example are pretty much equivalent, you can choose what you like more. Here I again generate a 1000x1000 Array of objects like this { text: i + " - " + j } with row index i and column index j. To every tenth row and column, I add an attribute merge, which is filled with{rowspan:4, colspan:7}, so this is the top-left cell of a merged block of size 8x7. If this is cell (10,10), the cells up to cell (13,16) get an attribute mergehead, which points to the top-left cell: { row: 10, col: 10 }.

The function spans returns the props for the cells, so:

The code is a bit lengthy. The basic idea is that you only need to look at the top row and the most left column of the items array to handle overlapping merged blocks, which are only partially in the visible window.

tablemerge

Here is the complete code:

<script> export let items = []; export let rowHeight; $: ({ top, left } = items[0].style); $: [rowmin, colmin] = [items[0].rowIndex, items[0].columnIndex]; $: [[rowmax, colmax, tablewidth]] = items .slice(-1) .map((v) => [ v.rowIndex, v.columnIndex, v.style.left + v.style.width - left, v.style.top + v.style.height - top, ]); $: (nrows = rowmax - rowmin + 1); $: (ncols = colmax - colmin + 1); $: (isScrolling = items[0].isScrolling); $: (rows = Array(nrows).fill(false)); $: (cols = Array(ncols).fill(true)); const data = (() => { return new Array(1000).fill(true).map((v, i) => new Array(1000).fill(true).map((w, j) => { return { text: i + " - " + j }; }) ); })(); for (let a = 0; a < 99; a++) { for (let b = 0; b < 99; b++) { for (let i = 0; i < 4; i++) { for (let j = 0; j < 7; j++) { if (i === 0 && j === 0) { data[10*a + i][10 * b + j].merge = { rowspan: 4, colspan: 7 }; } else { data[10 * a + i][10 * b + j].mergehead = { row: 10 * a, col: 10 * b }; } } } } } let rowspanblock = 1, colspanblock = 1; const clampspan = (r, c, obj) => { const re = {}; if (obj.rowspan) { re.rowspan = Math.min(obj.rowspan, nrows - r); } if (obj.colspan) { re.colspan = Math.min(obj.colspan, ncols - c); } return re; }; // r and c start with 0 const spans = (r, c) => { const d = data[r + rowmin][c + colmin]; if (d.merge) { if (d.merge) { if (c === 0) { rowspanblock = d.merge.rowspan || 1; } if (r === 0) { colspanblock = d.merge.colspan || 1; } return clampspan(r, c, d.merge); } } if (r === 0 && c === 0) { rowspanblock = 1; colspanblock = 1; } if (c === 0 && rowspanblock > 1) { rowspanblock--; return { class: "noshow" }; } if (r === 0 && colspanblock > 1) { colspanblock--; return { class: "noshow" }; } if (!d.mergehead) { return {}; } if (r !== 0 && c !== 0) { return { class: "noshow" }; } const head = data[d.mergehead.row][d.mergehead.col].merge; let rs = (head.rowspan || 1) + (r === 0 ? d.mergehead.row - rowmin : 0); let cs = (head.colspan || 1) + (c === 0 ? d.mergehead.col - colmin : 0); if (c === 0) { rowspanblock = rs; } if (r === 0) { colspanblock = cs; } return clampspan(r, c, { rowspan: rs, colspan: cs, }); }; </script> <style> table, td { border: 1px solid black; border-collapse: collapse; } .noshow { display: none; } </style> <table style={'table-layout: fixed;position: absolute;top: 0;left: 0;width:' + tablewidth + 'px;transform:translate(' + left + 'px,' + top + 'px);'}> {#each rows as i, r} <tr> {#each cols as j, c} <td {...spans(r, c)} style={`height:${rowHeight}px;`}> {isScrolling ? '...' : r + rowmin + ' - ' + (c + colmin)} </td> {/each} </tr> {/each} </table>

Conclusion

With svelte-window I tried to port react-window as closely as possible, but some deviations are inevitable. Here I showed, that this leads to new opportunities: you can merge cells in big grids with good performance. There are many more ways, how you could implement cell-merging and the best solution depends on your use-case.

I showed two examples, one with the canonical way of using windowed tables with divs, and one with html tables. Feel free to use any part of the code shown here in your projects.

Share this article on twitter - Discuss on Reddit - Vote on next topics - Support me

This might also be interesting for you:

Support me:

or directly

Design & Logo © 2020 Michael Lucht. All rights reserved.