Experimental chart=heatgrid implementation.

Heatgrid is a non-gviz based visualization that plots each value in a 2d
grid as one pixel/rectangle on a chart, with the intensity of each pixel set
according to the value in that cell.  You can mouseover the chart to see the
actual values.

This isn't perfect yet, but I think this is a decent first version.
diff --git a/heatgrid.js b/heatgrid.js
new file mode 100644
index 0000000..628383f
--- /dev/null
+++ b/heatgrid.js
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2013 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+'use strict';
+
+var HeatGrid = function(el) {
+  this.el = $(el);
+
+  var rgb = function(r, g, b) {
+    return 'rgb(' + parseInt(r) + ',' + parseInt(g) + ',' + parseInt(b) + ')';
+  }
+
+  var frac = function(minval, maxval, fraction) {
+    return minval + fraction * (maxval - minval);
+  }
+
+  var gradient = function(mincolor, zerocolor, maxcolor,
+                          ofs) {
+    if (ofs == 0) {
+      return [zerocolor[0], zerocolor[1], zerocolor[2], 0];
+    } else if (ofs < 0) {
+      return [frac(zerocolor[0], mincolor[0], -ofs/3),
+              frac(zerocolor[1], mincolor[1], -ofs/3),
+              frac(zerocolor[2], mincolor[2], -ofs/3)];
+    } else if (ofs > 0) {
+      return [frac(zerocolor[0], maxcolor[0], ofs/3),
+              frac(zerocolor[1], maxcolor[1], ofs/3),
+              frac(zerocolor[2], maxcolor[2], ofs/3)];
+    }
+  }
+
+  this.draw = function(grid) {
+    console.debug('heatgrid.draw', grid);
+    this.el.html('<div id="heatgrid"><canvas></canvas>' +
+                 '<div id="heatgrid-popover"></div></div>');
+    var heatgrid = this.el.find('#heatgrid');
+    heatgrid.css({
+      position: 'relative',
+      overflow: 'scroll',
+      width: '100%',
+      height: '100%',
+    });
+    var popover = this.el.find('#heatgrid-popover');
+    popover.css({
+      position: 'absolute',
+      top: 0, left: 0,
+      background: '#aaa',
+      border: '1px dotted black',
+      'white-space': 'pre'
+    });
+    var canvas = this.el.find('canvas');
+    var xmult = parseInt(1000 / grid.headers.length);
+    if (xmult < 1) xmult = 1;
+    var xsize = grid.headers.length * xmult;
+    var ysize = grid.data.length;
+    canvas.attr({width: xsize, height: ysize});
+    canvas.css({
+      background: '#fff',
+      width: '100%',
+      height: ysize //'100%',
+    });
+    console.debug('heatgrid canvas size is: x y =', xsize, ysize);
+    var ctx = canvas[0].getContext('2d');
+
+    if (!grid.data.length || !grid.data[0].length) {
+      return;
+    }
+
+    // TODO(apenwarr): offsetX/Y are flakey, use something else
+    var movefunc = function(offX, offY) {
+      var x = parseInt(offX / canvas.width() * grid.headers.length);
+      var y = parseInt(offY / canvas.height() * grid.data.length);
+      if (x > grid.headers.length || y > grid.data.length) return;
+      var info = [];
+      for (var i = 0; i < grid.headers.length; i++) {
+        if (grid.types[i] != 'number') {
+          info.push(grid.data[y][i]);
+        } else {
+          break;
+        }
+      }
+      info.push(grid.headers[x]);
+      info.push('value=' + grid.data[y][x]);
+
+      popover.css({
+        left: (x + 0.4) / grid.headers.length * canvas.width(),
+        top: (y + 0.4) / grid.data.length * canvas.height(),
+      });
+      popover.text(info.join('\n'));
+    };
+    heatgrid.mousemove(function(ev) {
+      var pos = canvas.position();
+      movefunc(ev.pageX - pos.left, ev.pageY - pos.top);
+    });
+    heatgrid.mouseleave(function() {
+      popover.hide();
+    });
+    heatgrid.mouseenter(function() {
+      popover.show();
+    });
+
+
+    var total = 0, count = 0;
+    for (var y = 0; y < grid.data.length; y++) {
+      for (var x = 0; x < grid.data[y].length; x++) {
+        if (grid.types[x] != 'number') continue;
+        var cell = parseFloat(grid.data[y][x]);
+        if (!isNaN(cell)) {
+          total += cell;
+          count++;
+        }
+      }
+    }
+    var avg = total / count;
+
+    var tdiff = 0;
+    for (var y = 0; y < grid.data.length; y++) {
+      for (var x = 0; x < grid.data[y].length; x++) {
+        if (grid.types[x] != 'number') continue;
+        var cell = parseFloat(grid.data[y][x]);
+        if (!isNaN(cell)) {
+          tdiff += (cell - avg) * (cell - avg);
+        }
+      }
+    }
+    var stddev = Math.sqrt(tdiff / count);
+
+    var img = ctx.createImageData(xsize, ysize);
+    for (var y = 0; y < grid.data.length; y++) {
+      for (var x = 0; x < grid.data[y].length; x++) {
+        if (grid.types[x] != 'number') continue;
+        var cell = parseFloat(grid.data[y][x]);
+        if (isNaN(cell)) continue;
+        var color = gradient(//[255,0,0], [192,192,192], [0,0,255],
+                             [192,192,192], [192,192,255], [0,0,255],
+                             (cell - avg) / stddev);
+        var pix = (y * xsize + x*xmult) * 4;
+        for (var i = 0; i < xmult; i++) {
+          img.data[pix + 0] = color[0];
+          img.data[pix + 1] = color[1];
+          img.data[pix + 2] = color[2];
+          img.data[pix + 3] = 255;
+          pix += 4;
+        }
+      }
+    }
+    ctx.putImageData(img, 0, 0);
+  }
+};
diff --git a/render.html b/render.html
index 5199565..e27a804 100644
--- a/render.html
+++ b/render.html
@@ -67,6 +67,7 @@
     packages: ['table', 'corechart', 'treemap', 'annotatedtimeline']
   });
   </script>
+  <script src="heatgrid.js"></script>
   <script src="render.js"></script>
 </head>
 
diff --git a/render.js b/render.js
index 36b816b..5a1f6b8 100644
--- a/render.js
+++ b/render.js
@@ -1315,6 +1315,8 @@
           if (charttype == 'dygraph+errors') {
             options.errorBars = true;
           }
+        } else if (charttype == 'heatgrid') {
+          t = new HeatGrid(el);
         } else {
           throw new Error('unknown chart type "' + charttype + '"');
         }
@@ -1325,19 +1327,24 @@
         gridoptions.allowHtml = true;
         options.allowHtml = true;
       }
-      datatable = dataToGvizTable(grid, gridoptions);
 
-      var dateformat = new google.visualization.DateFormat({
-        pattern: 'yyyy-MM-dd'
-      });
-      var datetimeformat = new google.visualization.DateFormat({
-        pattern: 'yyyy-MM-dd HH:mm:ss'
-      });
-      for (var coli = 0; coli < grid.types.length; coli++) {
-        if (grid.types[coli] === T_DATE) {
-          dateformat.format(datatable, coli);
-        } else if (grid.types[coli] === T_DATETIME) {
-          datetimeformat.format(datatable, coli);
+      if (charttype == 'heatgrid') {
+        datatable = grid;
+      } else {
+        datatable = dataToGvizTable(grid, gridoptions);
+
+        var dateformat = new google.visualization.DateFormat({
+          pattern: 'yyyy-MM-dd'
+        });
+        var datetimeformat = new google.visualization.DateFormat({
+          pattern: 'yyyy-MM-dd HH:mm:ss'
+        });
+        for (var coli = 0; coli < grid.types.length; coli++) {
+          if (grid.types[coli] === T_DATE) {
+            dateformat.format(datatable, coli);
+          } else if (grid.types[coli] === T_DATETIME) {
+            datetimeformat.format(datatable, coli);
+          }
         }
       }
       done(grid);
@@ -1346,18 +1353,18 @@
     enqueue(queue, chartops ? 'chart=' + chartops : 'view',
             function(grid, done) {
       if (grid.data.length) {
-	var doRender = function() {
-	  var wantwidth = trace ? window.innerWidth - 40 : window.innerWidth;
-	  $(el).width(wantwidth);
-	  $(el).height(window.innerHeight);
-	  options.height = window.innerHeight;
-	  t.draw(datatable, options);
-	}
-	doRender();
-	$(window).resize(function() {
-	  clearTimeout(resizeTimer);
-	  resizeTimer = setTimeout(doRender, 200);
-	});
+        var doRender = function() {
+          var wantwidth = trace ? window.innerWidth - 40 : window.innerWidth;
+          $(el).width(wantwidth);
+          $(el).height(window.innerHeight);
+          options.height = window.innerHeight;
+          t.draw(datatable, options);
+        }
+        doRender();
+        $(window).resize(function() {
+          clearTimeout(resizeTimer);
+          resizeTimer = setTimeout(doRender, 200);
+        });
       } else {
         el.innerHTML = 'Empty dataset.';
       }