dependence on PlotKit.Canvas is severed
[dygraphs.git] / dygraph-canvas.js
index e03c840..0813691 100644 (file)
  * @param {Object} options Options for PlotKit.Layout
  * @return {Object} The DygraphLayout object
  */
-DygraphLayout = function(options) {
-  PlotKit.Layout.call(this, "line", options);
+DygraphLayout = function(dygraph, options) {
+  this.dygraph_ = dygraph;
+  this.options = {};  // TODO(danvk): remove, use attr_ instead.
+  MochiKit.Base.update(this.options, options ? options : {});
+  this.datasets = new Array();
+};
+
+DygraphLayout.prototype.attr_ = function(name) {
+  return this.dygraph_.attr_(name);
+};
+
+DygraphLayout.prototype.addDataset = function(setname, set_xy) {
+  this.datasets[setname] = set_xy;
+};
+
+DygraphLayout.prototype.evaluate = function() {
+  this._evaluateLimits();
+  this._evaluateLineCharts();
+  this._evaluateLineTicks();
+};
+
+DygraphLayout.prototype._evaluateLimits = function() {
+  this.minxval = this.maxxval = null;
+  for (var name in this.datasets) {
+    var series = this.datasets[name];
+    var x1 = series[0][0];
+    if (!this.minxval || x1 < this.minxval) this.minxval = x1;
+
+    var x2 = series[series.length - 1][0];
+    if (!this.maxxval || x2 > this.maxxval) this.maxxval = x2;
+  }
+  this.xrange = this.maxxval - this.minxval;
+  this.xscale = (this.xrange != 0 ? 1/this.xrange : 1.0);
+
+  this.minyval = this.options.yAxis[0];
+  this.maxyval = this.options.yAxis[1];
+  this.yrange = this.maxyval - this.minyval;
+  this.yscale = (this.yrange != 0 ? 1/this.yrange : 1.0);
+};
+
+DygraphLayout.prototype._evaluateLineCharts = function() {
+  // add all the rects
+  this.points = new Array();
+  for (var setName in this.datasets) {
+    var dataset = this.datasets[setName];
+    for (var j = 0; j < dataset.length; j++) {
+      var item = dataset[j];
+      var point = {
+        x: ((parseFloat(item[0]) - this.minxval) * this.xscale),
+        y: 1.0 - ((parseFloat(item[1]) - this.minyval) * this.yscale),
+        xval: parseFloat(item[0]),
+        yval: parseFloat(item[1]),
+        name: setName
+      };
+
+      // limit the x, y values so they do not overdraw
+      if (point.y <= 0.0) {
+        point.y = 0.0;
+      }
+      if (point.y >= 1.0) {
+        point.y = 1.0;
+      }
+      if ((point.x >= 0.0) && (point.x <= 1.0)) {
+        this.points.push(point);
+      }
+    }
+  }
 };
-DygraphLayout.prototype = new PlotKit.Layout();
+
+DygraphLayout.prototype._evaluateLineTicks = function() {
+  this.xticks = new Array();
+  for (var i = 0; i < this.options.xTicks.length; i++) {
+    var tick = this.options.xTicks[i];
+    var label = tick.label;
+    var pos = this.xscale * (tick.v - this.minxval);
+    if ((pos >= 0.0) && (pos <= 1.0)) {
+      this.xticks.push([pos, label]);
+    }
+  }
+
+  this.yticks = new Array();
+  for (var i = 0; i < this.options.yTicks.length; i++) {
+    var tick = this.options.yTicks[i];
+    var label = tick.label;
+    var pos = 1.0 - (this.yscale * (tick.v - this.minyval));
+    if ((pos >= 0.0) && (pos <= 1.0)) {
+      this.yticks.push([pos, label]);
+    }
+  }
+};
+
 
 /**
  * Behaves the same way as PlotKit.Layout, but also copies the errors
@@ -75,19 +162,111 @@ DygraphLayout.prototype.updateOptions = function(new_options) {
  * @param {Layout} layout The DygraphLayout object for this graph.
  * @param {Object} options Options to pass on to CanvasRenderer
  */
-DygraphCanvasRenderer = function(element, layout, options) {
-  PlotKit.CanvasRenderer.call(this, element, layout, options);
-  this.options.shouldFill = false;
-  this.options.shouldStroke = true;
-  this.options.drawYGrid = true;
-  this.options.drawXGrid = true;
-  this.options.gridLineColor = MochiKit.Color.Color.grayColor();
+DygraphCanvasRenderer = function(dygraph, element, layout, options) {
+  // TODO(danvk): remove options, just use dygraph.attr_.
+  this.dygraph_ = dygraph;
+
+  // default options
+  this.options = {
+      "strokeWidth": 0.5,
+      "drawXAxis": true,
+      "drawYAxis": true,
+      "axisLineColor": Color.blackColor(),
+      "axisLineWidth": 0.5,
+      "axisTickSize": 3,
+      "axisLabelColor": Color.blackColor(),
+      "axisLabelFont": "Arial",
+      "axisLabelFontSize": 9,
+      "axisLabelWidth": 50,
+      "drawYGrid": true,
+      "drawXGrid": true,
+      "gridLineColor": MochiKit.Color.Color.grayColor()
+  };
   MochiKit.Base.update(this.options, options);
 
-  // TODO(danvk) This shouldn't be necessary: effects should be overlaid
-  this.options.drawBackground = false;
+  this.layout = layout;
+  this.element = MochiKit.DOM.getElement(element);
+  this.container = this.element.parentNode;
+
+  // Stuff relating to Canvas on IE support    
+  this.isIE = PlotKit.Base.excanvasSupported();
+
+  if (this.isIE && !isNil(G_vmlCanvasManager)) {
+      this.IEDelay = 0.5;
+      this.maxTries = 5;
+      this.renderDelay = null;
+      this.clearDelay = null;
+      this.element = G_vmlCanvasManager.initElement(this.element);
+  }
+
+  this.height = this.element.height;
+  this.width = this.element.width;
+
+  // --- check whether everything is ok before we return
+  if (!this.isIE && !(DygraphCanvasRenderer.isSupported(this.element)))
+      throw "Canvas is not supported.";
+
+  // internal state
+  this.xlabels = new Array();
+  this.ylabels = new Array();
+
+  this.area = {
+    x: this.options.yAxisLabelWidth + 2 * this.options.axisTickSize,
+    y: 0
+  };
+  this.area.w = this.width - this.area.x - this.options.rightGap;
+  this.area.h = this.height - this.options.axisLabelFontSize -
+                2 * this.options.axisTickSize;
+
+  MochiKit.DOM.updateNodeAttributes(this.container, 
+    {"style":{ "position": "relative", "width": this.width + "px"}});
+};
+
+DygraphCanvasRenderer.prototype.clear = function() {
+  if (this.isIE) {
+    // VML takes a while to start up, so we just poll every this.IEDelay
+    try {
+      if (this.clearDelay) {
+        this.clearDelay.cancel();
+        this.clearDelay = null;
+      }
+      var context = this.element.getContext("2d");
+    }
+    catch (e) {
+      this.clearDelay = MochiKit.Async.wait(this.IEDelay);
+      this.clearDelay.addCallback(bind(this.clear, this));
+      return;
+    }
+  }
+
+  var context = this.element.getContext("2d");
+  context.clearRect(0, 0, this.width, this.height);
+
+  MochiKit.Iter.forEach(this.xlabels, MochiKit.DOM.removeElement);
+  MochiKit.Iter.forEach(this.ylabels, MochiKit.DOM.removeElement);
+  this.xlabels = new Array();
+  this.ylabels = new Array();
+};
+
+
+DygraphCanvasRenderer.isSupported = function(canvasName) {
+  var canvas = null;
+  try {
+    if (MochiKit.Base.isUndefinedOrNull(canvasName)) 
+      canvas = MochiKit.DOM.CANVAS({});
+    else
+      canvas = MochiKit.DOM.getElement(canvasName);
+    var context = canvas.getContext("2d");
+  }
+  catch (e) {
+    var ie = navigator.appVersion.match(/MSIE (\d\.\d)/);
+    var opera = (navigator.userAgent.toLowerCase().indexOf("opera") != -1);
+    if ((!ie) || (ie[1] < 6) || (opera))
+      return false;
+    return true;
+  }
+  return true;
 };
-DygraphCanvasRenderer.prototype = new PlotKit.CanvasRenderer();
 
 /**
  * Draw an X/Y grid on top of the existing plot
@@ -130,9 +309,128 @@ DygraphCanvasRenderer.prototype.render = function() {
   // Do the ordinary rendering, as before
   // TODO(danvk) Call super.render()
   this._renderLineChart();
-  this._renderLineAxis();
+  this._renderAxis();
+};
+
+
+DygraphCanvasRenderer.prototype._renderAxis = function() {
+  if (!this.options.drawXAxis && !this.options.drawYAxis)
+    return;
+
+  var context = this.element.getContext("2d");
+
+  var labelStyle = {"style":
+    {"position": "absolute",
+      "fontSize": this.options.axisLabelFontSize + "px",
+      "zIndex": 10,
+      "color": this.options.axisLabelColor.toRGBString(),
+      "width": this.options.axisLabelWidth + "px",
+      "overflow": "hidden"
+    }
+  };
+
+  // axis lines
+  context.save();
+  context.strokeStyle = this.options.axisLineColor.toRGBString();
+  context.lineWidth = this.options.axisLineWidth;
+
+
+  if (this.options.drawYAxis) {
+    if (this.layout.yticks) {
+      var drawTick = function(tick) {
+        if (typeof(tick) == "function") return;
+        var x = this.area.x;
+        var y = this.area.y + tick[0] * this.area.h;
+        context.beginPath();
+        context.moveTo(x, y);
+        context.lineTo(x - this.options.axisTickSize, y);
+        context.closePath();
+        context.stroke();
+
+        var label = DIV(labelStyle, tick[1]);
+        var top = (y - this.options.axisLabelFontSize / 2);
+        if (top < 0) top = 0;
+
+        if (top + this.options.axisLabelFontSize + 3 > this.height) {
+          label.style.bottom = "0px";
+        } else {
+          label.style.top = top + "px";
+        }
+        label.style.left = "0px";
+        label.style.textAlign = "right";
+        label.style.width = this.options.yAxisLabelWidth + "px";
+        MochiKit.DOM.appendChildNodes(this.container, label);
+        this.ylabels.push(label);
+      };
+
+      MochiKit.Iter.forEach(this.layout.yticks, bind(drawTick, this));
+
+      // The lowest tick on the y-axis often overlaps with the leftmost
+      // tick on the x-axis. Shift the bottom tick up a little bit to
+      // compensate if necessary.
+      var bottomTick = this.ylabels[0];
+      var fontSize = this.options.axisLabelFontSize;
+      var bottom = parseInt(bottomTick.style.top) + fontSize;
+      if (bottom > this.height - fontSize) {
+        bottomTick.style.top = (parseInt(bottomTick.style.top) -
+            fontSize / 2) + "px";
+      }
+    }
+
+    context.beginPath();
+    context.moveTo(this.area.x, this.area.y);
+    context.lineTo(this.area.x, this.area.y + this.area.h);
+    context.closePath();
+    context.stroke();
+  }
+
+  if (this.options.drawXAxis) {
+    if (this.layout.xticks) {
+      var drawTick = function(tick) {
+        if (typeof(dataset) == "function") return;
+
+        var x = this.area.x + tick[0] * this.area.w;
+        var y = this.area.y + this.area.h;
+        context.beginPath();
+        context.moveTo(x, y);
+        context.lineTo(x, y + this.options.axisTickSize);
+        context.closePath();
+        context.stroke();
+
+        var label = DIV(labelStyle, tick[1]);
+        label.style.textAlign = "center";
+        label.style.bottom = "0px";
+
+        var left = (x - this.options.axisLabelWidth/2);
+        if (left + this.options.axisLabelWidth > this.width) {
+          left = this.width - this.options.xAxisLabelWidth;
+          label.style.textAlign = "right";
+        }
+        if (left < 0) {
+          left = 0;
+          label.style.textAlign = "left";
+        }
+
+        label.style.left = left + "px";
+        label.style.width = this.options.xAxisLabelWidth + "px";
+        MochiKit.DOM.appendChildNodes(this.container, label);
+        this.xlabels.push(label);
+      };
+
+      MochiKit.Iter.forEach(this.layout.xticks, bind(drawTick, this));
+    }
+
+    context.beginPath();
+    context.moveTo(this.area.x, this.area.y + this.area.h);
+    context.lineTo(this.area.x + this.area.w, this.area.y + this.area.h);
+    context.closePath();
+    context.stroke();
+  }
+
+  context.restore();
 };
 
+
 /**
  * Overrides the CanvasRenderer method to draw error bars
  */
@@ -154,6 +452,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
   MochiKit.Iter.forEach(this.layout.points, updatePoint, this);
 
   // create paths
+  var isOK = function(x) { return x && !isNaN(x); };
   var makePath = function(ctx) {
     for (var i = 0; i < setCount; i++) {
       var setName = setNames[i];
@@ -164,20 +463,44 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       context.save();
       context.strokeStyle = color.toRGBString();
       context.lineWidth = this.options.strokeWidth;
-      ctx.beginPath();
       var point = this.layout.points[0];
-      var first_point = true;
-      var addPoint = function(ctx_, point) {
+      var pointSize = this.dygraph_.attr_("pointSize");
+      var prevX = null, prevY = null;
+      var drawPoints = this.dygraph_.attr_("drawPoints");
+      var points = this.layout.points;
+      for (var j = 0; j < points.length; j++) {
+        var point = points[j];
         if (point.name == setName) {
-          if (first_point)
-            ctx_.moveTo(point.canvasx, point.canvasy);
-          else
-            ctx_.lineTo(point.canvasx, point.canvasy);
-          first_point = false;
+          if (!isOK(point.canvasy)) {
+            // this will make us move to the next point, not draw a line to it.
+            prevX = prevY = null;
+          } else {
+            // A point is "isolated" if it is non-null but both the previous
+            // and next points are null.
+            var isIsolated = (!prevX && (j == points.length - 1 ||
+                                         !isOK(points[j+1].canvasy)));
+
+            if (!prevX) {
+              prevX = point.canvasx;
+              prevY = point.canvasy;
+            } else {
+              ctx.beginPath();
+              ctx.moveTo(prevX, prevY);
+              prevX = point.canvasx;
+              prevY = point.canvasy;
+              ctx.lineTo(prevX, prevY);
+              ctx.stroke();
+            }
+
+            if (drawPoints || isIsolated) {
+             ctx.beginPath();
+             ctx.fillStyle = color.toRGBString();
+             ctx.arc(point.canvasx, point.canvasy, pointSize, 0, 360, false);
+             ctx.fill();
+            }
+          }
         }
-      };
-      MochiKit.Iter.forEach(this.layout.points, partial(addPoint, ctx), this);
-      ctx.stroke();
+      }
     }
   };
 
@@ -198,6 +521,10 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       var errorTrapezoid = function(ctx_,point) {
         count++;
         if (point.name == setName) {
+          if (!point.y || isNaN(point.y)) {
+            prevX = -1;
+            return;
+          }
           var newYs = [ point.y - point.errorPlus * yscale,
                         point.y + point.errorMinus * yscale ];
           newYs[0] = this.area.h * newYs[0] + this.area.y;