A javascript bookmarklet for adding heatmaps to HTML tables

Heatmaps provide a useful visual technique that can be applied to tables of data to make it easier to interpret the data and find interesting features, especially when the table is quite large.

Heatmaps are applied to a table by colouring table cells according to the magnitude of the cell content (when the cell content contains a number) in relation to other cells in the table. In this snippet, we will compare a table cell with other cells in the same column to allocate the colour. Cells containing higher values will be allocated darker colours.

Here is an example table. Even though the table is small, it isn't easy to immediately get a feel for how the data changes month by month.

Month

Rainfall (mm)

Sunshine (hours)

Jan

100

22

Feb

70

27

Mar

95

24

Apr

65

34

May

71

36

Jun

56

53

Jul

40

71

Aug

85

57

Sep

34

66

Oct

57

41

Nov

110

25

Dec

76

30

Bookmarklets are a technique for embedding javascript code into HTML links using the javascript: protocol. The links can then be stored as bookmarks in the browser. Clicking a bookmarklet (a bookmark which executes javascript code) executes the javascript in the context of the current page. Bookmarklets raise important security concerns and should be approached with caution - always check the code and/or verify the source of a bookmarklet before running it on a web page. In this snippet we'll build a bookmarklet which enables the user to add or disable heatmaps on their table data.

First, lets try it. The following link contains the heatmap bookmarklet. Click on the link to activate the bookmarklet directly, then move your cursor over the table above. When you move the cursor over the table columns containing numeric data, the columns should be highlighted in yellow. If you click on the columns, the heatmap should be applied, colouring the cells of the column. Click again on the column to remove the heatmap.

The heatmaps applied to each column are independent and work by creating a colour gradient based on the minimum and maximum values in the column.

To use the bookmarklet on other web pages, in firefox, opera and chrome you can drag the link onto the bookmarks bar at the top of the window. Other browsers may vary. Now you can open a web page with another table (for example, try it out on the wikipedia page of the IRIS data set) and click on the bookmarklet you dragged to the bookmark bar to enable the heatmap on that control. Click on the bookmarklet again to disable the heatmap.

The overall structure of the bookmarklet is listed below, with the main sections of code replaced by comments. Note that the bookmarklet is designed to work as a toggle, with the first call setting up a global object window.heatmap to hold the bookmarklet's data, and subsequent calls toggling the heatmap on and off.

The first three sections of code, tabledata, tablescan and functions, are only executed on the first call to the bookmarklet - they identify tables in the current page, and the initialise data and functions that the bookmarklet uses.

The first section of code, tabledata, defines the TableData object. Each HTML table found in the page is represented by a TableData object. The constructor scans the rows and cells in the table and builds up a data structure which groups cells from each column together into Arrays.

functionTableData(table){this.columns=[];// scan all rows in the tablevarrows=table.getElementsByTagName("tr");for(varrowidx=0;rowidx<rows.length;rowidx+=1){varrow=rows[rowidx];// scan all the cells in the rowvarcols=rows[rowidx].getElementsByTagName("td");for(varcolidx=0;colidx<cols.length;colidx+=1){vartd=cols[colidx];varval=parseFloat(td.innerHTML);if(val){if(!this.columns[colidx]){// discover a new column, add it to the table data structure along// with the first cell in the columnthis.columns[colidx]={"min":val,"max":val,"tds":[td],"heated":0};}else{// existing column, adjust min and max statistics with the new valueif(val<this.columns[colidx]["min"]){this.columns[colidx]["min"]=val;}if(val>this.columns[colidx]["max"]){this.columns[colidx]["max"]=val;}// push the cell onto the list of cells in this columnthis.columns[colidx]["tds"].push(td);}}// attach a handy column index to the cell for use in callbackstd.heatmap_colidx=colidx;}}returnthis;};TableData.prototype.getColumn=function(ele){return(ele.heatmap_colidx>=0)?this.columns[ele.heatmap_colidx]:null;};TableData.prototype.applyToColumn=function(coldata,fn){for(vartdidx=0;tdidx<coldata["tds"].length;tdidx+=1){fn(coldata["tds"][tdidx],coldata);}};TableData.prototype.highlightCell=function(ele,stats){ele.style.backgroundColor="yellow";};TableData.prototype.restoreCell=function(ele,stats){ele.style.backgroundColor="white";};TableData.prototype.colourCell=function(ele,stats){varval=parseFloat(ele.innerHTML);varrange=stats["max"]-stats["min"];if(val&&range){varfrac=(val-stats["min"])/range;varcol=(255-Math.floor(255*frac)).toString(16);if(col.length<2){col='0'+col;}ele.style.backgroundColor='#'+col+'FFFF';}};TableData.prototype.highlight=function(event){varcoldata=this.getColumn(event.target);if(coldata&&coldata.heated==0){this.applyToColumn(coldata,this.highlightCell);}};TableData.prototype.restore=function(event){varcoldata=this.getColumn(event.target);if(coldata&&coldata.heated==0){this.applyToColumn(coldata,this.restoreCell);}};TableData.prototype.colour=function(event){varcoldata=this.getColumn(event.target);if(!coldata){return;}if(coldata.heated){this.applyToColumn(coldata,this.restoreCell);}else{this.applyToColumn(coldata,this.colourCell);}coldata.heated=1-coldata.heated;};

The next section of code, tablescan scans the current page for tables and constructs and stores TableData objects.

// search for all tables on this pagevartables=document.getElementsByTagName("table");for(vartidx=0;tidx<tables.length;tidx+=1){vartable=tables[tidx];varrowcount=0;// create a data structure to describe the tablevartabledata=newTableData(table);// completed scanning the table, associate the table with the collected datawindow.heatmap.tabledata[table]=tabledata;window.heatmap.tables.push(table);}

Next, in the functions section, a series of useful utility functions are defined and attached to the window.heatmap global object. These functions include callbacks that will be attached to table cells to highlight and apply the heatmap to a table column. Note that we make the assumption that the table uses a white background.

// given an element, find and return the enclosing table element, or null otherwisewindow.heatmap.getParentTable=function(elt){if(!elt){returnnull;}if(elt.localName=="table"){returnelt;}returnwindow.heatmap.getParentTable(elt.parentNode);};// get the TableData object associated with tg event's target, or null otherwisewindow.heatmap.getTable=function(event){vartable=window.heatmap.getParentTable(event.target);returntable?window.heatmap.tabledata[table]:null;};// callback for mousing over a table cell, highlight the columnwindow.heatmap.mouseovercb=function(event){vartable=window.heatmap.getTable(event);table.highlight(event);};// callback for mousing away from a table cell, restore the original backgound colourwindow.heatmap.mouseoutcb=function(event){vartable=window.heatmap.getTable(event);table.restore(event);};// callback for clicking on a table cell, toggle the heatmap on or off for the entire columnwindow.heatmap.mouseclickcb=function(event){vartable=window.heatmap.getTable(event);table.colour(event);};

Finally, in the callbacks section, we'll define some code which is executed each time the bookmarklet is clicked. This code adds and removes the callback functions defined in the functions section, to each cell in the tables, depending on whether the heatmap is being toggled on or off.

// for all the tables on this pagefor(vartidx=0;tidx<window.heatmap.tables.length;tidx+=1){vartabledata=window.heatmap.tabledata[window.heatmap.tables[tidx]];// go through each columnfor(varcidx=0;cidx<tabledata.columns.length;cidx+=1){varcol=tabledata.columns[cidx];if(!col){continue;}vartds=col["tds"];// go through each cell in the columnfor(vartdidx=0;tdidx<tds.length;tdidx+=1){vartd=tds[tdidx];// either add or remove event listeners, depending on whether the heatmap is enabled or disabled if(window.heatmap.enabled){td.addEventListener("mouseover",window.heatmap.mouseovercb,false);td.addEventListener("mouseout",window.heatmap.mouseoutcb,false);td.addEventListener("click",window.heatmap.mouseclickcb,false);}else{td.removeEventListener("mouseover",window.heatmap.mouseovercb,false);td.removeEventListener("mouseout",window.heatmap.mouseoutcb,false);td.removeEventListener("click",window.heatmap.mouseclickcb,false);}}}}

javascript:(function(){if(window.heatmap){window.heatmap.enabled=!window.heatmap.enabled;}else{functionTableData(table){this.columns=[];// scan all rows in the tablevarrows=table.getElementsByTagName("tr");for(varrowidx=0;rowidx<rows.length;rowidx+=1){varrow=rows[rowidx];// scan all the cells in the rowvarcols=rows[rowidx].getElementsByTagName("td");for(varcolidx=0;colidx<cols.length;colidx+=1){vartd=cols[colidx];varval=parseFloat(td.innerHTML);if(val){if(!this.columns[colidx]){// discover a new column, add it to the table data structure along// with the first cell in the columnthis.columns[colidx]={"min":val,"max":val,"tds":[td],"heated":0};}else{// existing column, adjust min and max statistics with the new valueif(val<this.columns[colidx]["min"]){this.columns[colidx]["min"]=val;}if(val>this.columns[colidx]["max"]){this.columns[colidx]["max"]=val;}// push the cell onto the list of cells in this columnthis.columns[colidx]["tds"].push(td);}}// attach a handy column index to the cell for use in callbackstd.heatmap_colidx=colidx;}}returnthis;};TableData.prototype.getColumn=function(ele){return(ele.heatmap_colidx>=0)?this.columns[ele.heatmap_colidx]:null;};TableData.prototype.applyToColumn=function(coldata,fn){for(vartdidx=0;tdidx<coldata["tds"].length;tdidx+=1){fn(coldata["tds"][tdidx],coldata);}};TableData.prototype.highlightCell=function(ele,stats){ele.style.backgroundColor="yellow";};TableData.prototype.restoreCell=function(ele,stats){ele.style.backgroundColor="white";};TableData.prototype.colourCell=function(ele,stats){varval=parseFloat(ele.innerHTML);varrange=stats["max"]-stats["min"];if(val&&range){varfrac=(val-stats["min"])/range;varcol=(255-Math.floor(255*frac)).toString(16);if(col.length<2){col='0'+col;}ele.style.backgroundColor='#'+col+'FFFF';}};TableData.prototype.highlight=function(event){varcoldata=this.getColumn(event.target);if(coldata&&coldata.heated==0){this.applyToColumn(coldata,this.highlightCell);}};TableData.prototype.restore=function(event){varcoldata=this.getColumn(event.target);if(coldata&&coldata.heated==0){this.applyToColumn(coldata,this.restoreCell);}};TableData.prototype.colour=function(event){varcoldata=this.getColumn(event.target);if(!coldata){return;}if(coldata.heated){this.applyToColumn(coldata,this.restoreCell);}else{this.applyToColumn(coldata,this.colourCell);}coldata.heated=1-coldata.heated;};window.heatmap={};window.heatmap.tabledata={};window.heatmap.tables=[];window.heatmap.enabled=true;// given an element, find and return the enclosing table element, or null otherwisewindow.heatmap.getParentTable=function(elt){if(!elt){returnnull;}if(elt.localName=="table"){returnelt;}returnwindow.heatmap.getParentTable(elt.parentNode);};// get the TableData object associated with tg event's target, or null otherwisewindow.heatmap.getTable=function(event){vartable=window.heatmap.getParentTable(event.target);returntable?window.heatmap.tabledata[table]:null;};// callback for mousing over a table cell, highlight the columnwindow.heatmap.mouseovercb=function(event){vartable=window.heatmap.getTable(event);table.highlight(event);};// callback for mousing away from a table cell, restore the original backgound colourwindow.heatmap.mouseoutcb=function(event){vartable=window.heatmap.getTable(event);table.restore(event);};// callback for clicking on a table cell, toggle the heatmap on or off for the entire columnwindow.heatmap.mouseclickcb=function(event){vartable=window.heatmap.getTable(event);table.colour(event);};// search for all tables on this pagevartables=document.getElementsByTagName("table");for(vartidx=0;tidx<tables.length;tidx+=1){vartable=tables[tidx];varrowcount=0;// create a data structure to describe the tablevartabledata=newTableData(table);// completed scanning the table, associate the table with the collected datawindow.heatmap.tabledata[table]=tabledata;window.heatmap.tables.push(table);}}// for all the tables on this pagefor(vartidx=0;tidx<window.heatmap.tables.length;tidx+=1){vartabledata=window.heatmap.tabledata[window.heatmap.tables[tidx]];// go through each columnfor(varcidx=0;cidx<tabledata.columns.length;cidx+=1){varcol=tabledata.columns[cidx];if(!col){continue;}vartds=col["tds"];// go through each cell in the columnfor(vartdidx=0;tdidx<tds.length;tdidx+=1){vartd=tds[tdidx];// either add or remove event listeners, depending on whether the heatmap is enabled or disabled if(window.heatmap.enabled){td.addEventListener("mouseover",window.heatmap.mouseovercb,false);td.addEventListener("mouseout",window.heatmap.mouseoutcb,false);td.addEventListener("click",window.heatmap.mouseclickcb,false);}else{td.removeEventListener("mouseover",window.heatmap.mouseovercb,false);td.removeEventListener("mouseout",window.heatmap.mouseoutcb,false);td.removeEventListener("click",window.heatmap.mouseclickcb,false);}}}}})();

To put the bookmarklet code into a link we need to perform a little URL encoding. I've found that the following fragment of python does the trick. There are some subtleties to be aware of because newlines are removed from the code, it is important to use semi-colon to terminate all javascript statements.