Support for Google Visualization API compatible setSelection method
[dygraphs.git] / dygraph.js
index 9611ba9..3f8f90e 100644 (file)
@@ -114,7 +114,11 @@ Dygraph.DEFAULT_ATTRS = {
   fractions: false,
   wilsonInterval: true,  // only relevant if fractions is true
   customBars: false,
-  fillGraph: false
+  fillGraph: false,
+  fillAlpha: 0.15,
+
+  stackedGraph: false,
+  hideOverlayOnMouseOut: true
 };
 
 // Various logging levels.
@@ -158,6 +162,7 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
   this.dateWindow_ = attrs.dateWindow || null;
   this.valueRange_ = attrs.valueRange || null;
   this.wilsonInterval_ = attrs.wilsonInterval || true;
+  this.is_initial_draw_ = true;
 
   // Clear the div. This ensure that, if multiple dygraphs are passed the same
   // div, then only one will be drawn.
@@ -183,6 +188,12 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
     this.height_ = (this.height_ * self.innerHeight / 100) - 10;
   }
 
+  // TODO(danvk): set fillGraph to be part of attrs_ here, not user_attrs_.
+  if (attrs['stackedGraph']) {
+    attrs['fillGraph'] = true;
+    // TODO(nikhilk): Add any other stackedGraph checks here.
+  }
+
   // Dygraphs has many options, some of which interact with one another.
   // To keep track of everything, we maintain two sets of options:
   //
@@ -269,6 +280,56 @@ Dygraph.prototype.xAxisRange = function() {
   return [left, right];
 };
 
+/**
+ * Returns the currently-visible y-range. This can be affected by zooming,
+ * panning or a call to updateOptions.
+ * Returns a two-element array: [bottom, top].
+ */
+Dygraph.prototype.yAxisRange = function() {
+  return this.displayedYRange_;
+};
+
+/**
+ * Convert from data coordinates to canvas/div X/Y coordinates.
+ * Returns a two-element array: [X, Y]
+ */
+Dygraph.prototype.toDomCoords = function(x, y) {
+  var ret = [null, null];
+  var area = this.plotter_.area;
+  if (x !== null) {
+    var xRange = this.xAxisRange();
+    ret[0] = area.x + (x - xRange[0]) / (xRange[1] - xRange[0]) * area.w;
+  }
+
+  if (y !== null) {
+    var yRange = this.yAxisRange();
+    ret[1] = area.y + (yRange[1] - y) / (yRange[1] - yRange[0]) * area.h;
+  }
+
+  return ret;
+};
+
+// TODO(danvk): use these functions throughout dygraphs.
+/**
+ * Convert from canvas/div coords to data coordinates.
+ * Returns a two-element array: [X, Y]
+ */
+Dygraph.prototype.toDataCoords = function(x, y) {
+  var ret = [null, null];
+  var area = this.plotter_.area;
+  if (x !== null) {
+    var xRange = this.xAxisRange();
+    ret[0] = xRange[0] + (x - area.x) / area.w * (xRange[1] - xRange[0]);
+  }
+
+  if (y !== null) {
+    var yRange = this.yAxisRange();
+    ret[1] = yRange[0] + (area.h - y) / area.h * (yRange[1] - yRange[0]);
+  }
+
+  return ret;
+};
+
 Dygraph.addEvent = function(el, evt, fn) {
   var normed_fn = function(e) {
     if (!e) var e = window.event;
@@ -281,6 +342,13 @@ Dygraph.addEvent = function(el, evt, fn) {
   }
 };
 
+Dygraph.clipCanvas_ = function(cnv, clip) {
+  var ctx = cnv.getContext("2d");
+  ctx.beginPath();
+  ctx.rect(clip.left, clip.top, clip.width, clip.height);
+  ctx.clip();
+};
+
 /**
  * Generates interface elements for the Dygraph: a containing div, a div to
  * display the current point, and a textbox to adjust the rolling average
@@ -296,8 +364,16 @@ Dygraph.prototype.createInterface_ = function() {
   this.graphDiv.style.height = this.height_ + "px";
   enclosing.appendChild(this.graphDiv);
 
+  var clip = {
+    top: 0,
+    left: this.attr_("yAxisLabelWidth") + 2 * this.attr_("axisTickSize")
+  };
+  clip.width = this.width_ - clip.left - this.attr_("rightGap");
+  clip.height = this.height_ - this.attr_("axisLabelFontSize")
+      - 2 * this.attr_("axisTickSize");
+  this.clippingArea_ = clip;
+
   // Create the canvas for interactive parts of the chart.
-  // this.canvas_ = document.createElement("canvas");
   this.canvas_ = Dygraph.createCanvas();
   this.canvas_.style.position = "absolute";
   this.canvas_.width = this.width_;
@@ -309,6 +385,10 @@ Dygraph.prototype.createInterface_ = function() {
   // ... and for static parts of the chart.
   this.hidden_ = this.createPlotKitCanvas_(this.canvas_);
 
+  // Make sure we don't overdraw.
+  Dygraph.clipCanvas_(this.hidden_, this.clippingArea_);
+  Dygraph.clipCanvas_(this.canvas_, this.clippingArea_);
+
   var dygraph = this;
   Dygraph.addEvent(this.hidden_, 'mousemove', function(e) {
     dygraph.mouseMove_(e);
@@ -340,7 +420,35 @@ Dygraph.prototype.createInterface_ = function() {
   this.createStatusMessage_();
   this.createRollInterface_();
   this.createDragInterface_();
-}
+};
+
+/**
+ * Detach DOM elements in the dygraph and null out all data references.
+ * Calling this when you're done with a dygraph can dramatically reduce memory
+ * usage. See, e.g., the tests/perf.html example.
+ */
+Dygraph.prototype.destroy = function() {
+  var removeRecursive = function(node) {
+    while (node.hasChildNodes()) {
+      removeRecursive(node.firstChild);
+      node.removeChild(node.firstChild);
+    }
+  };
+  removeRecursive(this.maindiv_);
+
+  var nullOut = function(obj) {
+    for (var n in obj) {
+      if (typeof(obj[n]) === 'object') {
+        obj[n] = null;
+      }
+    }
+  };
+
+  // These may not all be necessary, but it can't hurt...
+  nullOut(this.layout_);
+  nullOut(this.plotter_);
+  nullOut(this);
+};
 
 /**
  * Creates the canvas containing the PlotKit graph. Only plotkit ever draws on
@@ -350,9 +458,11 @@ Dygraph.prototype.createInterface_ = function() {
  * @private
  */
 Dygraph.prototype.createPlotKitCanvas_ = function(canvas) {
-  // var h = document.createElement("canvas");
   var h = Dygraph.createCanvas();
   h.style.position = "absolute";
+  // TODO(danvk): h should be offset from canvas. canvas needs to include
+  // some extra area to make it easier to zoom in on the far left and far
+  // right. h needs to be precisely the plot area, so that clipping occurs.
   h.style.top = canvas.style.top;
   h.style.left = canvas.style.left;
   h.width = this.width_;
@@ -413,8 +523,10 @@ Dygraph.prototype.setColors_ = function() {
     var val = this.attr_('colorValue') || 0.5;
     for (var i = 1; i <= num; i++) {
       if (!this.visibility()[i-1]) continue;
-      var hue = (1.0*i/(1+num));
-      this.colors_.push( Dygraph.hsvToRGB(hue, sat, val) );
+      // alternate colors for high contrast.
+      var idx = i - parseInt(i % 2 ? i / 2 : (i - num)/2, 10);
+      var hue = (1.0 * idx/ (1 + num));
+      this.colors_.push(Dygraph.hsvToRGB(hue, sat, val));
     }
   } else {
     for (var i = 0; i < num; i++) {
@@ -431,6 +543,15 @@ Dygraph.prototype.setColors_ = function() {
   Dygraph.update(this.layoutOptions_, this.attrs_);
 }
 
+/**
+ * Return the list of colors. This is either the list of colors passed in the
+ * attributes, or the autogenerated list of rgb(r,g,b) strings.
+ * @return {Array<string>} The list of colors.
+ */
+Dygraph.prototype.getColors = function() {
+  return this.colors_;
+};
+
 // The following functions are from quirksmode.org with a modification for Safari from
 // http://blog.firetree.net/2005/07/04/javascript-find-position/
 // http://www.quirksmode.org/js/findpos.html
@@ -465,6 +586,7 @@ Dygraph.findPosY = function(obj) {
 };
 
 
+
 /**
  * Create the div that contains information on the selected point(s)
  * This goes in the top right of the canvas, unless an external div has already
@@ -729,19 +851,10 @@ Dygraph.prototype.drawZoomRect_ = function(startX, endX, prevEndX) {
  */
 Dygraph.prototype.doZoom_ = function(lowX, highX) {
   // Find the earliest and latest dates contained in this canvasx range.
-  var points = this.layout_.points;
-  var minDate = null;
-  var maxDate = null;
-  // Find the nearest [minDate, maxDate] that contains [lowX, highX]
-  for (var i = 0; i < points.length; i++) {
-    var cx = points[i].canvasx;
-    var x = points[i].xval;
-    if (cx < lowX  && (minDate == null || x > minDate)) minDate = x;
-    if (cx > highX && (maxDate == null || x < maxDate)) maxDate = x;
-  }
-  // Use the extremes if either is missing
-  if (minDate == null) minDate = points[0].xval;
-  if (maxDate == null) maxDate = points[points.length-1].xval;
+  var r = this.toDataCoords(lowX, null);
+  var minDate = r[0];
+  r = this.toDataCoords(highX, null);
+  var maxDate = r[0];
 
   this.dateWindow_ = [minDate, maxDate];
   this.drawGraph_(this.rawData_);
@@ -790,11 +903,36 @@ Dygraph.prototype.mouseMove_ = function(event) {
   if (this.attr_("highlightCallback")) {
     var px = this.lastHighlightCallbackX;
     if (px !== null && lastx != px) {
-      this.attr_("highlightCallback")(event, lastx, this.selPoints_);
+      // only fire if the selected point has changed.
       this.lastHighlightCallbackX = lastx;
+      if (!this.attr_("stackedGraph")) {
+        this.attr_("highlightCallback")(event, lastx, this.selPoints_);
+      } else {
+        // "unstack" the points.
+        var callbackPoints = this.selPoints_.map(
+            function(p) { return {xval: p.xval, yval: p.yval, name: p.name} });
+        var cumulative_sum = 0;
+        for (var j = callbackPoints.length - 1; j >= 0; j--) {
+          callbackPoints[j].yval -= cumulative_sum;
+          cumulative_sum += callbackPoints[j].yval;
+        }
+        this.attr_("highlightCallback")(event, lastx, callbackPoints);
+      }
     }
   }
 
+  // Save last x position for callbacks.
+  this.lastx_ = lastx;
+  
+  this.updateSelection_();
+};
+
+/**
+ * Draw dots over the selectied points in the data series. This function
+ * takes care of cleanup of previously-drawn dots.
+ * @private
+ */
+Dygraph.prototype.updateSelection_ = function() {
   // Clear the previously drawn vertical, if there is one
   var circleSize = this.attr_('highlightCircleSize');
   var ctx = this.canvas_.getContext("2d");
@@ -809,7 +947,7 @@ Dygraph.prototype.mouseMove_ = function(event) {
     var canvasx = this.selPoints_[0].canvasx;
 
     // Set the status message to indicate the selected point(s)
-    var replace = this.attr_('xValueFormatter')(lastx, this) + ":";
+    var replace = this.attr_('xValueFormatter')(this.lastx_, this) + ":";
     var clen = this.colors_.length;
     for (var i = 0; i < this.selPoints_.length; i++) {
       if (!isOK(this.selPoints_[i].canvasy)) continue;
@@ -824,11 +962,8 @@ Dygraph.prototype.mouseMove_ = function(event) {
     }
     this.attr_("labelsDiv").innerHTML = replace;
 
-    // Save last x position for callbacks.
-    this.lastx_ = lastx;
-
     // Draw colored circles over the center of each selected point
-    ctx.save()
+    ctx.save();
     for (var i = 0; i < this.selPoints_.length; i++) {
       if (!isOK(this.selPoints_[i%clen].canvasy)) continue;
       ctx.beginPath();
@@ -844,16 +979,54 @@ Dygraph.prototype.mouseMove_ = function(event) {
 };
 
 /**
+ * Set manually set selected dots, and display information about them
+ * @param int row number that should by highlighted
+ *            false value clears the selection
+ * @public
+ */
+Dygraph.prototype.setSelection = function(row) {
+  // Extract the points we've selected
+  this.selPoints_ = [];
+  var pos = 0;
+  
+  if (row !== false) {
+    for (var i in this.layout_.datasets) {
+      this.selPoints_.push(this.layout_.points[pos+row]);
+      pos += this.layout_.datasets[i].length;
+    }
+    
+    this.lastx_ = this.selPoints_[0].xval;
+    this.updateSelection_();
+  } else {
+    this.lastx_ = -1;
+    this.clearSelection();
+  }
+
+};
+
+/**
  * The mouse has left the canvas. Clear out whatever artifacts remain
  * @param {Object} event the mouseout event from the browser.
  * @private
  */
 Dygraph.prototype.mouseOut_ = function(event) {
+  if (this.attr_("hideOverlayOnMouseOut")) {
+    this.clearSelection();
+  }
+};
+
+/**
+ * Remove all selection from the canvas
+ * @public
+ */
+Dygraph.prototype.clearSelection = function() {
   // Get rid of the overlay data
   var ctx = this.canvas_.getContext("2d");
   ctx.clearRect(0, 0, this.width_, this.height_);
   this.attr_("labelsDiv").innerHTML = "";
-};
+  this.selPoints_ = [];
+  this.lastx_ = -1;
+}
 
 Dygraph.zeropad = function(x) {
   if (x < 10) return "0" + x; else return "" + x;
@@ -872,10 +1045,8 @@ Dygraph.prototype.hmsString_ = function(date) {
     return zeropad(d.getHours()) + ":" +
            zeropad(d.getMinutes()) + ":" +
            zeropad(d.getSeconds());
-  } else if (d.getMinutes()) {
-    return zeropad(d.getHours()) + ":" + zeropad(d.getMinutes());
   } else {
-    return zeropad(d.getHours());
+    return zeropad(d.getHours()) + ":" + zeropad(d.getMinutes());
   }
 }
 
@@ -1263,11 +1434,19 @@ Dygraph.prototype.extremeValues_ = function(series) {
  * @private
  */
 Dygraph.prototype.drawGraph_ = function(data) {
+  // This is used to set the second parameter to drawCallback, below.
+  var is_initial_draw = this.is_initial_draw_;
+  this.is_initial_draw_ = false;
+
   var minY = null, maxY = null;
   this.layout_.removeAllDatasets();
   this.setColors_();
   this.attrs_['pointSize'] = 0.5 * this.attr_('highlightCircleSize');
 
+  // For stacked series.
+  var cumulative_y = [];
+  var stacked_datasets = [];
+
   // Loop over all fields in the dataset
   for (var i = 1; i < data[0].length; i++) {
     if (!this.visibility()[i - 1]) continue;
@@ -1280,16 +1459,31 @@ Dygraph.prototype.drawGraph_ = function(data) {
     series = this.rollingAverage(series, this.rollPeriod_);
 
     // Prune down to the desired range, if necessary (for zooming)
+    // Because there can be lines going to points outside of the visible area,
+    // we actually prune to visible points, plus one on either side.
     var bars = this.attr_("errorBars") || this.attr_("customBars");
     if (this.dateWindow_) {
       var low = this.dateWindow_[0];
       var high= this.dateWindow_[1];
       var pruned = [];
+      // TODO(danvk): do binary search instead of linear search.
+      // TODO(danvk): pass firstIdx and lastIdx directly to the renderer.
+      var firstIdx = null, lastIdx = null;
       for (var k = 0; k < series.length; k++) {
-        if (series[k][0] >= low && series[k][0] <= high) {
-          pruned.push(series[k]);
+        if (series[k][0] >= low && firstIdx === null) {
+          firstIdx = k;
+        }
+        if (series[k][0] <= high) {
+          lastIdx = k;
         }
       }
+      if (firstIdx === null) firstIdx = 0;
+      if (firstIdx > 0) firstIdx--;
+      if (lastIdx === null) lastIdx = series.length - 1;
+      if (lastIdx < series.length - 1) lastIdx++;
+      for (var k = firstIdx; k <= lastIdx; k++) {
+        pruned.push(series[k]);
+      }
       series = pruned;
     }
 
@@ -1305,15 +1499,40 @@ Dygraph.prototype.drawGraph_ = function(data) {
         vals[j] = [series[j][0],
                    series[j][1][0], series[j][1][1], series[j][1][2]];
       this.layout_.addDataset(this.attr_("labels")[i], vals);
+    } else if (this.attr_("stackedGraph")) {
+      var vals = [];
+      var l = series.length;
+      var actual_y;
+      for (var j = 0; j < l; j++) {
+        if (cumulative_y[series[j][0]] === undefined)
+          cumulative_y[series[j][0]] = 0;
+
+        actual_y = series[j][1];
+        cumulative_y[series[j][0]] += actual_y;
+
+        vals[j] = [series[j][0], cumulative_y[series[j][0]]]
+
+        if (!maxY || cumulative_y[series[j][0]] > maxY)
+          maxY = cumulative_y[series[j][0]];
+      }
+      stacked_datasets.push([this.attr_("labels")[i], vals]);
+      //this.layout_.addDataset(this.attr_("labels")[i], vals);
     } else {
       this.layout_.addDataset(this.attr_("labels")[i], series);
     }
   }
 
+  if (stacked_datasets.length > 0) {
+    for (var i = (stacked_datasets.length - 1); i >= 0; i--) {
+      this.layout_.addDataset(stacked_datasets[i][0], stacked_datasets[i][1]);
+    }
+  }
+
   // Use some heuristics to come up with a good maxY value, unless it's been
   // set explicitly by the user.
   if (this.valueRange_ != null) {
     this.addYTicks_(this.valueRange_[0], this.valueRange_[1]);
+    this.displayedYRange_ = this.valueRange_;
   } else {
     // This affects the calculation of span, below.
     if (this.attr_("includeZero") && minY > 0) {
@@ -1337,6 +1556,7 @@ Dygraph.prototype.drawGraph_ = function(data) {
     }
 
     this.addYTicks_(minAxisY, maxAxisY);
+    this.displayedYRange_ = [minAxisY, maxAxisY];
   }
 
   this.addXTicks_();
@@ -1350,7 +1570,7 @@ Dygraph.prototype.drawGraph_ = function(data) {
                                          this.canvas_.height);
 
   if (this.attr_("drawCallback") !== null) {
-    this.attr_("drawCallback")(this);
+    this.attr_("drawCallback")(this, is_initial_draw);
   }
 };
 
@@ -1500,7 +1720,7 @@ Dygraph.prototype.rollingAverage = function(originalData, rollPeriod) {
 Dygraph.dateParser = function(dateStr, self) {
   var dateStrSlashed;
   var d;
-  if (dateStr.length == 10 && dateStr.search("-") != -1) {  // e.g. '2009-07-12'
+  if (dateStr.search("-") != -1) {  // e.g. '2009-7-12' or '2009-07-12'
     dateStrSlashed = dateStr.replace("-", "/", "g");
     while (dateStrSlashed.search("-") != -1) {
       dateStrSlashed = dateStrSlashed.replace("-", "/");
@@ -1689,8 +1909,9 @@ Dygraph.prototype.parseArray_ = function(data) {
         return null;
       }
       if (parsedData[i][0] == null
-          || typeof(parsedData[i][0].getTime) != 'function') {
-        this.error("x value in row " << (1 + i) << " is not a Date");
+          || typeof(parsedData[i][0].getTime) != 'function'
+          || isNaN(parsedData[i][0].getTime())) {
+        this.error("x value in row " + (1 + i) + " is not a Date");
         return null;
       }
       parsedData[i][0] = parsedData[i][0].getTime();
@@ -2003,5 +2224,20 @@ Dygraph.GVizChart.prototype.draw = function(data, options) {
   this.date_graph = new Dygraph(this.container, data, options);
 }
 
+/**
+ * Google charts compatible setSelection
+ * Only row selection is supported, all points in the 
+ * row will be highlighted
+ * @param {Array} array of the selected cells
+ * @public
+ */
+Dygraph.GVizChart.prototype.setSelection = function(selection_array) {
+  var row = false;
+  if (selection_array.length) {
+    row = selection_array[0].row;
+  }
+  this.date_graph.setSelection(row);
+}
+
 // Older pages may still use this name.
 DateGraph = Dygraph;