` Heatmap - A javascript bookmarklet

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)
Jan10022
Feb7027
Mar9524
Apr6534
May7136
Jun5653
Jul4071
Aug8557
Sep3466
Oct5741
Nov11025
Dec7630

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.

heatmap bookmarklet

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.

heatmap_core.js
javascript:(function(){  
    if (window.heatmap) {
        window.heatmap.enabled = !window.heatmap.enabled;
    } else {

        /* tabledata code */

        window.heatmap = {};
        window.heatmap.tabledata = {};
        window.heatmap.tables = [];
        window.heatmap.enabled = true;
    
        /* functions code */
        /* tablescan code */
    }

    /* callbacks code */

})();

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.

tabledata.js
function TableData(table) {
    this.columns = [];
    // scan all rows in the table
    var rows = table.getElementsByTagName("tr");
    for(var rowidx=0; rowidx<rows.length;rowidx+=1) {
        var row = rows[rowidx];
        // scan all the cells in the row
        var cols = rows[rowidx].getElementsByTagName("td");
        for(var colidx=0; colidx<cols.length; colidx+=1) {
            var td = cols[colidx];
            var val = 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 column
                    this.columns[colidx] = { "min":val, "max":val, "tds":[td], "heated":0 };
                } else {
                    // existing column, adjust min and max statistics with the new value
                    if (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 column
                    this.columns[colidx]["tds"].push(td);
                }
            }
            // attach a handy column index to the cell for use in callbacks
            td.heatmap_colidx = colidx;
        }
    }
    return this;
};

TableData.prototype.getColumn = function(ele) {
    return (ele.heatmap_colidx >= 0) ? this.columns[ele.heatmap_colidx] : null;
};  

TableData.prototype.applyToColumn = function(coldata,fn) {
    for(var tdidx=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) {
    var val = parseFloat(ele.innerHTML);
    var range = stats["max"]-stats["min"];
    if (val && range) {
        var frac = (val - stats["min"])/range;
        var col = (255-Math.floor(255*frac)).toString(16);
        if (col.length < 2) {
            col = '0'+col;
        }
        ele.style.backgroundColor = '#'+ col +'FFFF';
    } 
};

TableData.prototype.highlight = function(event) {
    var coldata = this.getColumn(event.target);
    if (coldata && coldata.heated == 0) {            
        this.applyToColumn(coldata,this.highlightCell);
    }
};

TableData.prototype.restore = function(event) {
    var coldata = this.getColumn(event.target);
    if (coldata && coldata.heated == 0) {            
        this.applyToColumn(coldata,this.restoreCell);
    }
};

TableData.prototype.colour = function(event) {
    var coldata = 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.

tablescan.js
        

        // search for all tables on this page
        var tables = document.getElementsByTagName("table");
        for(var tidx=0; tidx<tables.length; tidx+=1) {
            var table = tables[tidx];
            var rowcount = 0;        
            // create a data structure to describe the table
            var tabledata = new TableData(table);
            
            // completed scanning the table, associate the table with the collected data
            window.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.

functions.js
        // given an element, find and return the enclosing table element, or null otherwise
        window.heatmap.getParentTable = function(elt) {
            if (!elt) { return null; }
            if (elt.localName == "table") { return elt; }
            return window.heatmap.getParentTable(elt.parentNode);
        };

        // get the TableData object associated with tg event's target, or null otherwise
        window.heatmap.getTable = function(event) {
            var table = window.heatmap.getParentTable(event.target);
            return table ? window.heatmap.tabledata[table] : null;
            
        };

        // callback for mousing over a table cell, highlight the column
        window.heatmap.mouseovercb = function(event) {
            var table = window.heatmap.getTable(event);
            table.highlight(event);
        };

        // callback for mousing away from a table cell, restore the original backgound colour
        window.heatmap.mouseoutcb = function(event) {
            var table = window.heatmap.getTable(event);
            table.restore(event);
        };

        // callback for clicking on a table cell, toggle the heatmap on or off for the entire column
        window.heatmap.mouseclickcb = function(event) {
            var table = 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.

callbacks.js
    
    // for all the tables on this page
    for(var tidx=0;tidx<window.heatmap.tables.length; tidx+=1) {
        var tabledata = window.heatmap.tabledata[window.heatmap.tables[tidx]];
        // go through each column
        for(var cidx=0; cidx<tabledata.columns.length;cidx+=1) {
            var col = tabledata.columns[cidx];
            if (!col) { continue; }
            var tds = col["tds"];
            // go through each cell in the column
            for(var tdidx=0; tdidx<tds.length;tdidx+=1) {
                var td = 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);
                }
            }
        }
    }

The final code assembled from these sections is listed below.

heatmap.js
javascript:(function(){  
    if (window.heatmap) {
        window.heatmap.enabled = !window.heatmap.enabled;
    } else {

        

function TableData(table) {
    this.columns = [];
    // scan all rows in the table
    var rows = table.getElementsByTagName("tr");
    for(var rowidx=0; rowidx<rows.length;rowidx+=1) {
        var row = rows[rowidx];
        // scan all the cells in the row
        var cols = rows[rowidx].getElementsByTagName("td");
        for(var colidx=0; colidx<cols.length; colidx+=1) {
            var td = cols[colidx];
            var val = 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 column
                    this.columns[colidx] = { "min":val, "max":val, "tds":[td], "heated":0 };
                } else {
                    // existing column, adjust min and max statistics with the new value
                    if (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 column
                    this.columns[colidx]["tds"].push(td);
                }
            }
            // attach a handy column index to the cell for use in callbacks
            td.heatmap_colidx = colidx;
        }
    }
    return this;
};

TableData.prototype.getColumn = function(ele) {
    return (ele.heatmap_colidx >= 0) ? this.columns[ele.heatmap_colidx] : null;
};  

TableData.prototype.applyToColumn = function(coldata,fn) {
    for(var tdidx=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) {
    var val = parseFloat(ele.innerHTML);
    var range = stats["max"]-stats["min"];
    if (val && range) {
        var frac = (val - stats["min"])/range;
        var col = (255-Math.floor(255*frac)).toString(16);
        if (col.length < 2) {
            col = '0'+col;
        }
        ele.style.backgroundColor = '#'+ col +'FFFF';
    } 
};

TableData.prototype.highlight = function(event) {
    var coldata = this.getColumn(event.target);
    if (coldata && coldata.heated == 0) {            
        this.applyToColumn(coldata,this.highlightCell);
    }
};

TableData.prototype.restore = function(event) {
    var coldata = this.getColumn(event.target);
    if (coldata && coldata.heated == 0) {            
        this.applyToColumn(coldata,this.restoreCell);
    }
};

TableData.prototype.colour = function(event) {
    var coldata = 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 otherwise
        window.heatmap.getParentTable = function(elt) {
            if (!elt) { return null; }
            if (elt.localName == "table") { return elt; }
            return window.heatmap.getParentTable(elt.parentNode);
        };

        // get the TableData object associated with tg event's target, or null otherwise
        window.heatmap.getTable = function(event) {
            var table = window.heatmap.getParentTable(event.target);
            return table ? window.heatmap.tabledata[table] : null;
            
        };

        // callback for mousing over a table cell, highlight the column
        window.heatmap.mouseovercb = function(event) {
            var table = window.heatmap.getTable(event);
            table.highlight(event);
        };

        // callback for mousing away from a table cell, restore the original backgound colour
        window.heatmap.mouseoutcb = function(event) {
            var table = window.heatmap.getTable(event);
            table.restore(event);
        };

        // callback for clicking on a table cell, toggle the heatmap on or off for the entire column
        window.heatmap.mouseclickcb = function(event) {
            var table = window.heatmap.getTable(event);
            table.colour(event);
        };   



        


        
        

        // search for all tables on this page
        var tables = document.getElementsByTagName("table");
        for(var tidx=0; tidx<tables.length; tidx+=1) {
            var table = tables[tidx];
            var rowcount = 0;        
            // create a data structure to describe the table
            var tabledata = new TableData(table);
            
            // completed scanning the table, associate the table with the collected data
            window.heatmap.tabledata[table] = tabledata;
            window.heatmap.tables.push(table);
        }

    }

    
    
    // for all the tables on this page
    for(var tidx=0;tidx<window.heatmap.tables.length; tidx+=1) {
        var tabledata = window.heatmap.tabledata[window.heatmap.tables[tidx]];
        // go through each column
        for(var cidx=0; cidx<tabledata.columns.length;cidx+=1) {
            var col = tabledata.columns[cidx];
            if (!col) { continue; }
            var tds = col["tds"];
            // go through each cell in the column
            for(var tdidx=0; tdidx<tds.length;tdidx+=1) {
                var td = 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.

buildbookmark.py
# open the specified javascript file, encode into a URL, output the URL to stdout
#    remove lines starting with //
#    remove newline characters
#    replace double quotes with single quotes
#    replace spaces with %20
def buildbookmark(jsfile):
    s = open(jsfile,"r").read()
    lines = s.split("\n")
    out = ""
    for line in lines:
        stripline = line.strip()
        if not stripline.startswith("//"):
            out += stripline.replace("\"","'").replace(" ","%20")
    
    return out

 

Leave a comment

Anti-Spam Check
Comment