I am continuing my series of POC components using Angular. Today I have decided to create a virtualized spreadsheet component. The idea is to make a highly scalable grid by limiting the number of rendered DOM rows to a fixed number – backed by a virtual data source. The point of all this is not really to recreate Excel, but to play around with some new Angular concepts.
Virtualization is great when dealing with large data sets since you don't have to allocate DOM elements to represent the entire data set. Instead a fixed collection of DOM nodes are generated against a small window into the larger data set.
I started out creating a Spreadsheet component as an entry point for the application.
Component
import {Component, Input, AfterViewChecked} from '@angular/core';
import {SpreadsheetModel} from './spreadsheetModel';
import {KeyMap} from './key-map';
import {HeaderRowService} from './header-row-service';
@Component({
selector: 'spreadsheet',
templateUrl: './components/spreadsheet/spreadsheet.html'
})
export class Spreadsheet implements AfterViewChecked {
model:SpreadsheetModel;
@Input() rows:Number;
@Input() columns:Number;
constructor(){
this.model = new SpreadsheetModel(10,4);
this.header = HeaderRowService.createHeader(this.model.rows[0].columns.length);
this.visibleRows = this.getVisibleRows();
}
getHeader(){
return HeaderRowService.createHeader(this.model.rows[0].columns.length);
}
navigate($event){
this.model.navigate($event.keyCode);
this.visibleRows = this.getVisibleRows();
}
ngAfterViewChecked(){
let cell = document.getElementById(this.model.current.rowIndex + '-' + this.model.current.columnIndex);
cell.focus();
}
getVisibleRows(){
return this.model.rows.filter((row) => row.rowIndex >= this.model.start && row.rowIndex < this.model.end);
}
getActive(col){
if(col === this.model.current){
return 'active-cell';
}
}
}
The next step is to create a model for managing user interaction with the grid. The model supports arrow key based navigation and will allocate new data rows if you navigate beyond the current “page”. For now I have only virtualized vertical navigation, but it could easily be extended to horizontal navigation as well.
Model
import {KeyMap} from './key-map';
import {Row} from './row';
import {Column} from './column';
export class SpreadsheetModel{
rows:Array<Row>;
current:Column;
start:number;
end:number;
constructor(public rowCount, public columnCount){
this.rows = [];
this.start = 0;
this.end = rowCount;
for(let i = 0; i < this.rowCount; i++){
this.rows.push(new Row(i,this.columnCount));
}
this.current = this.rows[0].columns[0];
}
selectColumn(col){
this.current = col;
}
navigate(keyCode){
const navDirection = KeyMap.getNavigationDirection(keyCode);
if(navDirection.down){
this.ensureRow();
this.current = this.rows[this.current.rowIndex + 1].columns[this.current.columnIndex];
this.adjustRowRangeDownward();
}
if(navDirection.up){
if(this.current.rowIndex === 0){
return;
}
this.current = this.rows[this.current.rowIndex - 1].columns[this.current.columnIndex];
this.adjustRowRangeUpward();
}
if(navDirection.left){
if(this.current.columnIndex === 0){
return;
}
this.current = this.rows[this.current.rowIndex].columns[this.current.columnIndex - 1];
}
if(navDirection.right){
if(this.current.columnIndex === this.columnCount - 1){
return;
}
this.current = this.rows[this.current.rowIndex].columns[this.current.columnIndex + 1];
}
}
adjustRowRangeUpward(){
if(this.current.rowIndex < this.start){
this.shiftRowsBy(-1);
}
}
adjustRowRangeDownward(){
if(this.current.rowIndex === this.end){
this.shiftRowsBy(1);
}
}
shiftRowsBy(offset){
this.start = this.start + offset;
this.end = this.end + offset;
}
ensureRow(){
if(this.current.rowIndex + 1 >= this.rows.length){
this.rows.push(new Row(this.current.rowIndex + 1,this.columnCount));
}
}
}
I have also added a two entities to represent rows and columns.
Entities
import {Column} from './column';
//Row
export class Row{
columns:Array;
constructor(public rowIndex, public columnCount){
this.columns = [];
for(let j = 0; j < this.columnCount; j++){
this.columns.push(new Column(j,this.rowIndex));
}
}
}
//Column
export class Column{
cellValue:String;
constructor(public columnIndex,public rowIndex){
this.cellValue = '';
}
}
Finally I have included the html template:
View
<table id="spreadsheet">
<tr>
<td class="row-number-column"></td>
<td class="columnHeader" *ngFor="let columnHeader of header">{{columnHeader}}</td>
</tr>
<tr *ngFor="let row of visibleRows">
<td class="row-number-column">{{row.rowIndex}}</td>
<td *ngFor="let col of row.columns">
<input data-id="{{col.rowIndex}}-{{col.columnIndex}}" [value]="col.cellValue" (input)="col.cellValue = $event.target.value" (click)="model.selectColumn(col)" (keyup)="navigate($event)" />
</td>
</tr>
</table>
As always there is also a live demo of the code:
Check it out here
The code is also on
Github