Merge branch 'master' of https://github.com/nealie/dygraphs into nealie
[dygraphs.git] / dygraph.js
index 5380f26..30ea7a8 100644 (file)
@@ -258,6 +258,10 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
   this.is_initial_draw_ = true;
   this.annotations_ = [];
 
+  // Zoomed indicators - These indicate when the graph has been zoomed and on what axis.
+  this.zoomed_x_ = false;
+  this.zoomed_y_ = false;
+
   // Number of digits to use when labeling the x (if numeric) and y axis
   // ticks.
   this.numXDigits_ = 2;
@@ -334,6 +338,22 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
   this.start_();
 };
 
+/**
+ * Returns the zoomed status of the chart for one or both axes.
+ *
+ * Axis is an optional parameter. Can be set to 'x' or 'y'.
+ *
+ * The zoomed status for an axis is set whenever a user zooms using the mouse
+ * or when the dateWindow or valueRange are updated (unless the isZoomedIgnoreProgrammaticZoom
+ * option is also specified).
+ */
+Dygraph.prototype.isZoomed = function(axis) {
+  if (axis == null) return this.zoomed_x_ || this.zoomed_y_;
+  if (axis == 'x') return this.zoomed_x_;
+  if (axis == 'y') return this.zoomed_y_;
+  throw "axis parameter to Dygraph.isZoomed must be missing, 'x' or 'y'.";
+};
+
 Dygraph.prototype.toString = function() {
   var maindiv = this.maindiv_;
   var id = (maindiv && maindiv.id) ? maindiv.id : maindiv
@@ -409,9 +429,14 @@ Dygraph.prototype.rollPeriod = function() {
  * If the Dygraph has dates on the x-axis, these will be millis since epoch.
  */
 Dygraph.prototype.xAxisRange = function() {
-  if (this.dateWindow_) return this.dateWindow_;
+  return this.dateWindow_ ? this.dateWindow_ : this.xAxisExtremes();
+};
 
-  // The entire chart is visible.
+/**
+ * Returns the lower- and upper-bound x-axis values of the
+ * data set.
+ */
+Dygraph.prototype.xAxisExtremes = function() {
   var left = this.rawData_[0][0];
   var right = this.rawData_[this.rawData_.length - 1][0];
   return [left, right];
@@ -564,7 +589,7 @@ Dygraph.prototype.toDataYCoord = function(y, axis) {
 
 /**
  * Converts a y for an axis to a percentage from the top to the
- * bottom of the div.
+ * bottom of the drawing area.
  *
  * If the coordinate represents a value visible on the canvas, then
  * the value will be between 0 and 1, where 0 is the top of the canvas.
@@ -585,8 +610,8 @@ Dygraph.prototype.toPercentYCoord = function(y, axis) {
 
   var pct;
   if (!this.axes_[axis].logscale) {
-    // yrange[1] - y is unit distance from the bottom.
-    // yrange[1] - yrange[0] is the scale of the range.
+    // yRange[1] - y is unit distance from the bottom.
+    // yRange[1] - yRange[0] is the scale of the range.
     // (yRange[1] - y) / (yRange[1] - yRange[0]) is the % from the bottom.
     pct = (yRange[1] - y) / (yRange[1] - yRange[0]);
   } else {
@@ -597,6 +622,26 @@ Dygraph.prototype.toPercentYCoord = function(y, axis) {
 }
 
 /**
+ * Converts an x value to a percentage from the left to the right of
+ * the drawing area.
+ *
+ * If the coordinate represents a value visible on the canvas, then
+ * the value will be between 0 and 1, where 0 is the left of the canvas.
+ * However, this method will return values outside the range, as
+ * values can fall outside the canvas.
+ *
+ * If x is null, this returns null.
+ */
+Dygraph.prototype.toPercentXCoord = function(x) {
+  if (x == null) {
+    return null;
+  }
+
+  var xRange = this.xAxisRange();
+  return (x - xRange[0]) / (xRange[1] - xRange[0]);
+}
+
+/**
  * Returns the number of columns (including the independent variable).
  */
 Dygraph.prototype.numColumns = function() {
@@ -1006,6 +1051,35 @@ Dygraph.startPan = function(event, g, context) {
   context.initialLeftmostDate = xRange[0];
   context.xUnitsPerPixel = context.dateRange / (g.plotter_.area.w - 1);
 
+  if (g.attr_("panEdgeFraction")) {
+    var maxXPixelsToDraw = g.width_ * g.attr_("panEdgeFraction");
+    var xExtremes = g.xAxisExtremes(); // I REALLY WANT TO CALL THIS xTremes!
+
+    var boundedLeftX = g.toDomXCoord(xExtremes[0]) - maxXPixelsToDraw;
+    var boundedRightX = g.toDomXCoord(xExtremes[1]) + maxXPixelsToDraw;
+
+    var boundedLeftDate = g.toDataXCoord(boundedLeftX);
+    var boundedRightDate = g.toDataXCoord(boundedRightX);
+    context.boundedDates = [boundedLeftDate, boundedRightDate];
+
+    var boundedValues = [];
+    var maxYPixelsToDraw = g.height_ * g.attr_("panEdgeFraction");
+
+    for (var i = 0; i < g.axes_.length; i++) {
+      var axis = g.axes_[i];
+      var yExtremes = axis.extremeRange;
+
+      var boundedTopY = g.toDomYCoord(yExtremes[0], i) + maxYPixelsToDraw;
+      var boundedBottomY = g.toDomYCoord(yExtremes[1], i) - maxYPixelsToDraw;
+
+      var boundedTopValue = g.toDataYCoord(boundedTopY);
+      var boundedBottomValue = g.toDataYCoord(boundedBottomY);
+
+      boundedValues[i] = [boundedTopValue, boundedBottomValue];
+    }
+    context.boundedValues = boundedValues;
+  }
+
   // Record the range of each y-axis at the start of the drag.
   // If any axis has a valueRange or valueWindow, then we want a 2D pan.
   context.is2DPan = false;
@@ -1041,7 +1115,18 @@ Dygraph.movePan = function(event, g, context) {
 
   var minDate = context.initialLeftmostDate -
     (context.dragEndX - context.dragStartX) * context.xUnitsPerPixel;
+  if (context.boundedDates) {
+    minDate = Math.max(minDate, context.boundedDates[0]);
+  }
   var maxDate = minDate + context.dateRange;
+  if (context.boundedDates) {
+    if (maxDate > context.boundedDates[1]) {
+      // Adjust minDate, and recompute maxDate.
+      minDate = minDate - (maxDate - context.boundedDates[1]);
+      maxDate = minDate + context.dateRange;
+    }
+  }
+
   g.dateWindow_ = [minDate, maxDate];
 
   // y-axis scaling is automatic unless this is a full 2D pan.
@@ -1052,10 +1137,22 @@ Dygraph.movePan = function(event, g, context) {
 
       var pixelsDragged = context.dragEndY - context.dragStartY;
       var unitsDragged = pixelsDragged * axis.unitsPerPixel;
+      var boundedValue = context.boundedValues ? context.boundedValues[i] : null;
 
       // In log scale, maxValue and minValue are the logs of those values.
       var maxValue = axis.initialTopValue + unitsDragged;
+      if (boundedValue) {
+        maxValue = Math.min(maxValue, boundedValue[1]);
+      }
       var minValue = maxValue - axis.dragValueRange;
+      if (boundedValue) {
+        if (minValue < boundedValue[0]) {
+          // Adjust maxValue, and recompute minValue.
+          maxValue = maxValue - (minValue - boundedValue[0]);
+          minValue = maxValue - axis.dragValueRange;
+        }
+      }
       if (axis.logscale) {
         axis.valueWindow = [ Math.pow(Dygraph.LOG_SCALE, minValue),
                              Math.pow(Dygraph.LOG_SCALE, maxValue) ];
@@ -1084,6 +1181,8 @@ Dygraph.endPan = function(event, g, context) {
   context.initialLeftmostDate = null;
   context.dateRange = null;
   context.valueRange = null;
+  context.boundedDates = null;
+  context.boundedValues = null;
 }
 
 // Called in response to an interaction model operation that
@@ -1273,6 +1372,11 @@ Dygraph.prototype.createDragInterface_ = function() {
     px: 0,
     py: 0,
 
+    // Values for use with panEdgeFraction, which limit how far outside the
+    // graph's data boundaries it can be panned.
+    boundedDates: null, // [minDate, maxDate]
+    boundedValues: null, // [[minValue, maxValue] ...]
+
     initializeMouseDown: function(event, g, context) {
       // prevents mouse drags from selecting page text.
       if (event.preventDefault) {
@@ -1411,6 +1515,7 @@ Dygraph.prototype.doZoomX_ = function(lowX, highX) {
  */
 Dygraph.prototype.doZoomXDates_ = function(minDate, maxDate) {
   this.dateWindow_ = [minDate, maxDate];
+  this.zoomed_x_ = true;
   this.drawGraph_();
   if (this.attr_("zoomCallback")) {
     this.attr_("zoomCallback")(minDate, maxDate, this.yAxisRanges());
@@ -1438,9 +1543,11 @@ Dygraph.prototype.doZoomY_ = function(lowY, highY) {
     valueRanges.push([low, hi]);
   }
 
+  this.zoomed_y_ = true;
   this.drawGraph_();
   if (this.attr_("zoomCallback")) {
     var xRange = this.xAxisRange();
+    var yRange = this.yAxisRange();
     this.attr_("zoomCallback")(xRange[0], xRange[1], this.yAxisRanges());
   }
 };
@@ -1468,6 +1575,8 @@ Dygraph.prototype.doUnzoom_ = function() {
   if (dirty) {
     // Putting the drawing operation before the callback because it resets
     // yAxisRange.
+    this.zoomed_x_ = false;
+    this.zoomed_y_ = false;
     this.drawGraph_();
     if (this.attr_("zoomCallback")) {
       var minDate = this.rawData_[0][0];
@@ -1485,12 +1594,12 @@ Dygraph.prototype.doUnzoom_ = function() {
  * @private
  */
 Dygraph.prototype.mouseMove_ = function(event) {
-  var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_);
-  var points = this.layout_.points;
-
   // This prevents JS errors when mousing over the canvas before data loads.
+  var points = this.layout_.points;
   if (points === undefined) return;
 
+  var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_);
+
   var lastx = -1;
   var lasty = -1;
 
@@ -2003,7 +2112,7 @@ Dygraph.prototype.GetXAxis = function(start_time, end_time, granularity) {
       if (i % year_mod != 0) continue;
       for (var j = 0; j < months.length; j++) {
         var date_str = i + "/" + zeropad(1 + months[j]) + "/01";
-        var t = Date.parse(date_str);
+        var t = Dygraph.dateStrToMillis(date_str);
         if (t < start_time || t > end_time) continue;
         ticks.push({ v:t, label: formatter(new Date(t), granularity) });
       }
@@ -2492,15 +2601,20 @@ Dygraph.prototype.drawGraph_ = function() {
     this.layout_.addDataset(this.attr_("labels")[i], datasets[i]);
   }
 
-  this.computeYAxisRanges_(extremes);
-  this.layout_.updateOptions( { yAxes: this.axes_,
-                                seriesToAxisMap: this.seriesToAxisMap_
-                              } );
-
+  if (datasets.length > 0) {
+    // TODO(danvk): this method doesn't need to return anything.
+    this.computeYAxisRanges_(extremes);
+    this.layout_.updateOptions( { yAxes: this.axes_,
+                                  seriesToAxisMap: this.seriesToAxisMap_
+                                } );
+  }
   this.addXTicks_();
 
+  // Save the X axis zoomed status as the updateOptions call will tend to set it errorneously
+  var tmp_zoomed_x = this.zoomed_x_;
   // Tell PlotKit to use this new data and render itself
   this.layout_.updateOptions({dateWindow: this.dateWindow_});
+  this.zoomed_x_ = tmp_zoomed_x;
   this.layout_.evaluateWithError();
   this.plotter_.clear();
   this.plotter_.render();
@@ -2528,6 +2642,15 @@ Dygraph.prototype.drawGraph_ = function() {
  *   indices are into the axes_ array.
  */
 Dygraph.prototype.computeYAxes_ = function() {
+  var valueWindows;
+  if (this.axes_ != undefined) {
+    // Preserve valueWindow settings.
+    valueWindows = [];
+    for (var index = 0; index < this.axes_.length; index++) {
+      valueWindows.push(this.axes_[index].valueWindow);
+    }
+  }
+
   this.axes_ = [{ yAxisId : 0, g : this }];  // always have at least one y-axis.
   this.seriesToAxisMap_ = {};
 
@@ -2604,6 +2727,13 @@ Dygraph.prototype.computeYAxes_ = function() {
     if (vis[i - 1]) seriesToAxisFiltered[s] = this.seriesToAxisMap_[s];
   }
   this.seriesToAxisMap_ = seriesToAxisFiltered;
+
+  if (valueWindows != undefined) {
+    // Restore valueWindow settings.
+    for (var index = 0; index < valueWindows.length; index++) {
+      this.axes_[index].valueWindow = valueWindows[index];
+    }
+  }
 };
 
 /**
@@ -2635,28 +2765,52 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
     seriesForAxis[idx].push(series);
   }
 
+  // If no series are defined or visible then fill in some reasonable defaults.
+  if (seriesForAxis.length == 0) {
+    var axis = this.axes_[0];
+    axis.computedValueRange = [0, 1];
+    var ret =
+      Dygraph.numericTicks(axis.computedValueRange[0],
+                           axis.computedValueRange[1],
+                           this,
+                           axis);
+    axis.ticks = ret.ticks;
+    this.numYDigits_ = ret.numDigits;
+    return;
+  }
+
   // Compute extreme values, a span and tick marks for each axis.
   for (var i = 0; i < this.axes_.length; i++) {
     var axis = this.axes_[i];
-    if (axis.valueWindow) {
-      // This is only set if the user has zoomed on the y-axis. It is never set
-      // by a user. It takes precedence over axis.valueRange because, if you set
-      // valueRange, you'd still expect to be able to pan.
-      axis.computedValueRange = [axis.valueWindow[0], axis.valueWindow[1]];
-    } else if (axis.valueRange) {
-      // This is a user-set value range for this axis.
-      axis.computedValueRange = [axis.valueRange[0], axis.valueRange[1]];
-    } else {
+    {
       // Calculate the extremes of extremes.
       var series = seriesForAxis[i];
       var minY = Infinity;  // extremes[series[0]][0];
       var maxY = -Infinity;  // extremes[series[0]][1];
+      var extremeMinY, extremeMaxY;
       for (var j = 0; j < series.length; j++) {
-        minY = Math.min(extremes[series[j]][0], minY);
-        maxY = Math.max(extremes[series[j]][1], maxY);
+        // Only use valid extremes to stop null data series' from corrupting the scale.
+        extremeMinY = extremes[series[j]][0];
+        if (extremeMinY != null) {
+            minY = Math.min(extremeMinY, minY);
+        }
+        extremeMaxY = extremes[series[j]][1];
+        if (extremeMaxY != null) {
+            maxY = Math.max(extremeMaxY, maxY);
+        }
       }
       if (axis.includeZero && minY > 0) minY = 0;
 
+      // Ensure we have a valid scale, otherwise defualt to zero for safety.
+      if (minY == Infinity) {
+        minY = 0;
+      }
+
+      if (maxY == -Infinity) {
+        maxY = 0;
+      }
+
       // Add some padding and round up to an integer to be human-friendly.
       var span = maxY - minY;
       // special case: if we have no sense of scale, use +/-10% of the sole value.
@@ -2682,8 +2836,18 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
           if (minY > 0) minAxisY = 0;
         }
       }
-
-      axis.computedValueRange = [minAxisY, maxAxisY];
+      axis.extremeRange = [minAxisY, maxAxisY];
+    }
+    if (axis.valueWindow) {
+      // This is only set if the user has zoomed on the y-axis. It is never set
+      // by a user. It takes precedence over axis.valueRange because, if you set
+      // valueRange, you'd still expect to be able to pan.
+      axis.computedValueRange = [axis.valueWindow[0], axis.valueWindow[1]];
+    } else if (axis.valueRange) {
+      // This is a user-set value range for this axis.
+      axis.computedValueRange = [axis.valueRange[0], axis.valueRange[1]];
+    } else {
+      axis.computedValueRange = axis.extremeRange;
     }
 
     // Add ticks. By default, all axes inherit the tick positions of the
@@ -2871,16 +3035,16 @@ Dygraph.dateParser = function(dateStr, self) {
     while (dateStrSlashed.search("-") != -1) {
       dateStrSlashed = dateStrSlashed.replace("-", "/");
     }
-    d = Date.parse(dateStrSlashed);
+    d = Dygraph.dateStrToMillis(dateStrSlashed);
   } else if (dateStr.length == 8) {  // e.g. '20090712'
     // TODO(danvk): remove support for this format. It's confusing.
     dateStrSlashed = dateStr.substr(0,4) + "/" + dateStr.substr(4,2)
                        + "/" + dateStr.substr(6,2);
-    d = Date.parse(dateStrSlashed);
+    d = Dygraph.dateStrToMillis(dateStrSlashed);
   } else {
     // Any format that Date.parse will accept, e.g. "2009/07/12" or
     // "2009/07/12 12:34:56"
-    d = Date.parse(dateStr);
+    d = Dygraph.dateStrToMillis(dateStr);
   }
 
   if (!d || isNaN(d)) {
@@ -3240,6 +3404,11 @@ Dygraph.prototype.parseDataTable_ = function(data) {
           annotations.push(ann);
         }
       }
+
+      // Strip out infinities, which give dygraphs problems later on.
+      for (var j = 0; j < row.length; j++) {
+        if (!isFinite(row[j])) row[j] = null;
+      }
     } else {
       for (var j = 0; j < cols - 1; j++) {
         row.push([ data.getValue(i, 1 + 2 * j), data.getValue(i, 2 + 2 * j) ]);
@@ -3248,11 +3417,6 @@ Dygraph.prototype.parseDataTable_ = function(data) {
     if (ret.length > 0 && row[0] < ret[ret.length - 1][0]) {
       outOfOrder = true;
     }
-
-    // Strip out infinities, which give dygraphs problems later on.
-    for (var j = 0; j < row.length; j++) {
-      if (!isFinite(row[j])) row[j] = null;
-    }
     ret.push(row);
   }
 
@@ -3267,6 +3431,13 @@ Dygraph.prototype.parseDataTable_ = function(data) {
   }
 }
 
+// This is identical to JavaScript's built-in Date.parse() method, except that
+// it doesn't get replaced with an incompatible method by aggressive JS
+// libraries like MooTools or Joomla.
+Dygraph.dateStrToMillis = function(str) {
+  return new Date(str).getTime();
+};
+
 // These functions are all based on MochiKit.
 Dygraph.update = function (self, o) {
   if (typeof(o) != 'undefined' && o !== null) {
@@ -3361,6 +3532,12 @@ Dygraph.prototype.start_ = function() {
  * <li>file: changes the source data for the graph</li>
  * <li>errorBars: changes whether the data contains stddev</li>
  * </ul>
+ *
+ * If the dateWindow or valueRange options are specified, the relevant zoomed_x_
+ * or zoomed_y_ flags are set, unless the isZoomedIgnoreProgrammaticZoom option is also
+ * secified. This allows for the chart to be programmatically zoomed without
+ * altering the zoomed flags.
+ *
  * @param {Object} attrs The new properties and values
  */
 Dygraph.prototype.updateOptions = function(attrs) {
@@ -3370,6 +3547,12 @@ Dygraph.prototype.updateOptions = function(attrs) {
   }
   if ('dateWindow' in attrs) {
     this.dateWindow_ = attrs.dateWindow;
+    if (!('isZoomedIgnoreProgrammaticZoom' in attrs)) {
+      this.zoomed_x_ = attrs.dateWindow != null;
+    }
+  }
+  if ('valueRange' in attrs && !('isZoomedIgnoreProgrammaticZoom' in attrs)) {
+    this.zoomed_y_ = attrs.valueRange != null;
   }
 
   // TODO(danvk): validate per-series options.
@@ -3841,7 +4024,7 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
     "default": "false",
     "labels": ["Axis display"],
     "type": "boolean",
-    "description": "When set for a y-axis, the graph shows that axis in y-scale. Any values less than or equal to zero are not displayed.\n\nNot compatible with showZero, and ignores connectSeparatedPoints. Also, showing log scale with valueRanges that are less than zero will result in an unviewable graph."
+    "description": "When set for a y-axis, the graph shows that axis in log scale. Any values less than or equal to zero are not displayed.\n\nNot compatible with showZero, and ignores connectSeparatedPoints. Also, showing log scale with valueRanges that are less than zero will result in an unviewable graph."
   },
   "strokeWidth": {
     "default": "1.0",
@@ -4020,6 +4203,19 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
     "labels": ["Annotations"],
     "type": "boolean",
     "description": "Only applies when Dygraphs is used as a GViz chart. Causes string columns following a data series to be interpreted as annotations on points in that series. This is the same format used by Google's AnnotatedTimeLine chart."
+  },
+  "panEdgeFraction": {
+    "default": "null",
+    "labels": ["Axis Display", "Interactive Elements"],
+    "type": "float",
+    "default": "null",
+    "description": "A value representing the farthest a graph may be panned, in percent of the display. For example, a value of 0.1 means that the graph can only be panned 10% pased the edges of the displayed values. null means no bounds."
+  },
+  "isZoomedIgnoreProgrammaticZoom" : {
+    "default": "false",
+    "labels": ["Zooming"],
+    "type": "boolean",
+    "description" : "When this flag is passed along with either the <code>dateWindow</code> or <code>valueRange</code> options, the zoom flags are not changed to reflect a zoomed state. This is primarily useful for when the display area of a chart is changed programmatically and also where manual zooming is allowed and use is made of the <code>isZoomed</code> method to determine this."
   }
 }
 ;  // </JSON>