Add new options xRangePad and yRangePad
authorKlaus Weidner <klausw@google.com>
Sat, 9 Feb 2013 01:01:10 +0000 (17:01 -0800)
committerKlaus Weidner <klausw@google.com>
Sat, 9 Feb 2013 01:01:10 +0000 (17:01 -0800)
These options provide a fixed margin (in pixels) around the
user-specified or autoselected axis range to ensure all data points are
fully visible. If these are not set by the user, behavior reverts to
backward compatible mode.

This helps avoid the issue of data points drawn at the graph edges not
being properly visible.  For example, in a 100-pixel high plot area, a Y
coordinate of zero on a graph with yrange [0, ...] will be scaled to
domY=100 which is outside the 0..99 visible area.

If I'm understanding it right, the original padding algorithm works
as follows:

  Add 10% padding for automatic Y ranges, but not for user-supplied
  ranges, and move a close-to-zero edge to zero except if avoidMinZero
  is set. If logscale is set, add a variable amount of padding at the
  top but none at the bottom.

This is not very intuitive, and lines drawn at the edge of a
user-supplied range will still be invisible.

The new algorithm consistently ensures that there are at least
{x,y}RangePad pixels available for drawing outside of the data range,
both for user-specified and autoselected ranges.

Setting a small xRangePad combines nicely with drawAxesAtZero,
it will provide tick marks next to the legend due to the grid extending
slightly past the axis.

See the included gallery demo 'Edge Padding'. The default setting
corresponds to legacy behavior. Recommended settings:
  includeZero: true,
  drawAxesAtZero: true,
  xRangePad: 4,
  yRangePad: 10

auto_tests/tests/pathological_cases.js
auto_tests/tests/range_tests.js
dygraph-layout.js
dygraph-options-reference.js
dygraph.js
gallery/edge-padding.js [new file with mode: 0644]
gallery/gallery.css
gallery/index.html

index 83cba83..502c8fd 100644 (file)
@@ -36,6 +36,86 @@ pathologicalCasesTestCase.prototype.testOnePoint = function() {
   var g = new Dygraph(graph, data, opts);
 };
 
+pathologicalCasesTestCase.prototype.testCombinations = function() {
+  var dataSets = {
+    empty: [],
+    onePoint: [[10, 2]],
+    nanPoint: [[10, NaN]],
+    nanPoints: [[10, NaN], [20, NaN]],
+    multiNan1: [[10, NaN, 2], [20, 3, NaN]],
+    multiNan2: [[10, NaN, 2], [20, NaN, 4]],
+    multiNan3: [[10, NaN, NaN], [20, 3, 4], [30, NaN, NaN]],
+    atZero: [[0, 0]],
+    atZero2: [[0, 0, 0]],
+    negative: [[-10, -1]],
+    acrossZero: [[-10, 1], [10, 2]],
+    normal: [[0,1,9], [10,3,5], [20,2,7], [30,4,3]]
+  };
+
+  var baseOpts = {
+    lines: {},
+    stacked: {
+      stackedGraph: true
+    }
+  };
+
+  var variantOpts = {
+    none: {},
+    avoidMinZero: {
+      avoidMinZero: true,
+      includeZero: true
+    },
+    padded: {
+      includeZero: true,
+      drawAxesAtZero: true,
+      xRangePad: 0.02,
+      yRangePad: 0.04
+    }
+  };
+
+  for (var baseName in baseOpts) {
+    var base = baseOpts[baseName];
+    for (var variantName in variantOpts) {
+      var variant = variantOpts[variantName];
+
+      var opts = {
+        width: 300,
+        height: 150,
+        labelsDivWidth: 100,
+        pointSize: 10
+      };
+      for (var key in base) {
+        if (base.hasOwnProperty(key)) opts[key] = base[key];
+      }
+      for (var key in variant) {
+        if (variant.hasOwnProperty(key)) opts[key] = variant[key];
+      }
+
+      var h = document.createElement('h3');
+      h.appendChild(document.createTextNode(baseName + ' ' + variantName));
+      document.body.appendChild(h);
+      for (var dataName in dataSets) {
+        var data = dataSets[dataName];
+
+        var box = document.createElement('fieldset');
+        box.style.display = 'inline-block';
+        var legend = document.createElement('legend');
+        legend.appendChild(document.createTextNode(dataName));
+        box.appendChild(legend);
+        var gdiv = document.createElement('div');
+        gdiv.style.display = 'inline-block';
+        box.appendChild(gdiv);
+        document.body.appendChild(box);
+
+        var cols = data && data[0] ? data[0].length : 0;
+        opts.labels = ['X', 'A', 'B', 'C'].slice(0, cols);
+
+        var g = new Dygraph(gdiv, data, opts);
+      }
+    }
+  }
+};
+
 pathologicalCasesTestCase.prototype.testNullLegend = function() {
   var opts = {
     width: 480,
index 83a2505..2b2e59a 100644 (file)
@@ -45,12 +45,15 @@ RangeTestCase.prototype.setUp = function() {
   document.body.innerHTML = "<div id='graph'></div>";
 };
 
-RangeTestCase.prototype.createGraph = function(opts) {
+RangeTestCase.prototype.createGraph = function(opts, data, expectRangeX, expectRangeY) {
+  if (data === undefined) data = ZERO_TO_FIFTY_STEPS;
+  if (expectRangeX === undefined) expectRangeX = [10, 20];
+  if (expectRangeY === undefined) expectRangeY = [0, 55];
   var graph = document.getElementById("graph");
-  var g = new Dygraph(graph, ZERO_TO_FIFTY_STEPS, opts);
+  var g = new Dygraph(graph, data, opts);
 
-  assertEquals([10, 20], g.xAxisRange());
-  assertEquals([0, 55], g.yAxisRange(0));
+  assertEqualsDelta(expectRangeX, g.xAxisRange(), 0.01);
+  assertEqualsDelta(expectRangeY, g.yAxisRange(0), 0.01);
 
   return g;
 };
@@ -272,3 +275,96 @@ RangeTestCase.prototype.testHugeRange = function() {
   assertEqualsDelta(1, -1e229 / g.yAxisRange(0)[0], 0.001);
   assertEqualsDelta(1, 1.1e230 / g.yAxisRange(0)[1], 0.001);
 }
+
+/**
+ * Verify old-style avoidMinZero option.
+ */
+RangeTestCase.prototype.testAvoidMinZero = function() {
+  var g = this.createGraph({
+      avoidMinZero: true,
+    }, ZERO_TO_FIFTY_STEPS, [10, 20], [-5, 55]);
+};
+
+/**
+ * Verify ranges with user-specified padding, implicit avoidMinZero.
+ */
+RangeTestCase.prototype.testPaddingAuto = function() {
+  var g = this.createGraph({
+      xRangePad: 42,
+      yRangePad: 30
+    }, ZERO_TO_FIFTY_STEPS, [9, 21], [-5, 55]);
+};
+
+/**
+ * Verify auto range with drawAxesAtZero.
+ */
+RangeTestCase.prototype.testPaddingAutoAxisAtZero = function() {
+  var g = this.createGraph({
+      drawAxesAtZero: true,
+    }, ZERO_TO_FIFTY_STEPS, [10, 20], [0, 55]);
+};
+
+/**
+ * Verify user-specified range with padding and drawAxesAtZero options.
+ * Try explicit range matching the auto range, should have identical results.
+ */
+RangeTestCase.prototype.testPaddingRange1 = function() {
+  var g = this.createGraph({
+      valueRange: [0, 50],
+      xRangePad: 42,
+      yRangePad: 30,
+      drawAxesAtZero: true
+    }, ZERO_TO_FIFTY_STEPS, [9, 21], [-5, 55]);
+};
+
+/**
+ * Verify user-specified range with padding and drawAxesAtZero options.
+ * User-supplied range differs from the auto range.
+ */
+RangeTestCase.prototype.testPaddingRange2 = function() {
+  var g = this.createGraph({
+      valueRange: [10, 60],
+      xRangePad: 42,
+      yRangePad: 30,
+      drawAxesAtZero: true,
+    }, ZERO_TO_FIFTY_STEPS, [9, 21], [5, 65]);
+};
+
+/**
+ * Verify drawAxesAtZero and includeZero.
+ */
+RangeTestCase.prototype.testPaddingYAtZero = function() {
+  var g = this.createGraph({
+      includeZero: true,
+      xRangePad: 42,
+      yRangePad: 30,
+      drawAxesAtZero: true,
+    }, [
+      [-10, 10],
+      [10, 20],
+      [30, 50]
+    ], [-14, 34], [-5, 55]);
+};
+
+/**
+ * Verify logscale, compat mode.
+ */
+RangeTestCase.prototype.testLogscaleCompat = function() {
+  var g = this.createGraph({
+      logscale: true
+    },
+    [[-10, 10], [10, 10], [30, 1000]],
+    [-10, 30], [10, 1099]);
+};
+
+/**
+ * Verify logscale, new mode.
+ */
+RangeTestCase.prototype.testLogscalePad = function() {
+  var g = this.createGraph({
+      logscale: true,
+      yRangePad: 30
+    },
+    [[-10, 10], [10, 10], [30, 1000]],
+    [-10, 30], [5.01691, 1993.25801]);
+};
index b99896b..3e8a761 100644 (file)
@@ -168,24 +168,11 @@ DygraphLayout.prototype.evaluate = function() {
 };
 
 DygraphLayout.prototype._evaluateLimits = function() {
-  this.minxval = this.maxxval = null;
-  if (this.dateWindow_) {
-    this.minxval = this.dateWindow_[0];
-    this.maxxval = this.dateWindow_[1];
-  } else {
-    for (var setIdx = 0; setIdx < this.datasets.length; ++setIdx) {
-      var series = this.datasets[setIdx];
-      if (series.length > 1) {
-        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);
+  var xlimits = this.dygraph_.xAxisRange();
+  this.minxval = xlimits[0];
+  this.maxxval = xlimits[1];
+  var xrange = xlimits[1] - xlimits[0];
+  this.xscale = (xrange !== 0 ? 1 / xrange : 1.0);
 
   for (var i = 0; i < this.yAxes_.length; i++) {
     var axis = this.yAxes_[i];
index 0f601d1..4554995 100644 (file)
@@ -485,9 +485,9 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
   },
   "avoidMinZero": {
     "default": "false",
-    "labels": ["Axis display"],
+    "labels": ["Deprecated"],
     "type": "boolean",
-    "description": "When set, the heuristic that fixes the Y axis at zero for a data set with the minimum Y value of zero is disabled. \nThis is particularly useful for data sets that contain many zero values, especially for step plots which may otherwise have lines not visible running along the bottom axis."
+    "description": "Deprecated, please use yRangePad instead. When set, the heuristic that fixes the Y axis at zero for a data set with the minimum Y value of zero is disabled. \nThis is particularly useful for data sets that contain many zero values, especially for step plots which may otherwise have lines not visible running along the bottom axis."
   },
   "drawAxesAtZero": {
     "default": "false",
@@ -495,6 +495,18 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
     "type": "boolean",
     "description": "When set, draw the X axis at the Y=0 position and the Y axis at the X=0 position if those positions are inside the graph's visible area. Otherwise, draw the axes at the bottom or left graph edge as usual."
   },
+  "xRangePad": {
+    "default": "0",
+    "labels": ["Axis display"],
+    "type": "float",
+    "description": "Add the specified amount of extra space (in pixels) around the X-axis value range to ensure points at the edges remain visible."
+  },
+  "yRangePad": {
+    "default": "null",
+    "labels": ["Axis display"],
+    "type": "float",
+    "description": "If set, add the specified amount of extra space (in pixels) around the Y-axis value range to ensure points at the edges remain visible. If unset, use the traditional Y padding algorithm."
+  },
   "xAxisLabelFormatter": {
     "default": "",
     "labels": ["Deprecated"],
index 941badb..0c00c84 100644 (file)
@@ -246,6 +246,8 @@ Dygraph.DEFAULT_ATTRS = {
 
   stepPlot: false,
   avoidMinZero: false,
+  xRangePad: 0,
+  yRangePad: null,
   drawAxesAtZero: false,
 
   // Sizes of the various chart labels.
@@ -657,8 +659,18 @@ Dygraph.prototype.xAxisRange = function() {
  * data set.
  */
 Dygraph.prototype.xAxisExtremes = function() {
+  var pad = this.attr_('xRangePad') / this.plotter_.area.w;
+  if (!this.numRows() > 0) {
+    return [0 - pad, 1 + pad];
+  }
   var left = this.rawData_[0][0];
   var right = this.rawData_[this.rawData_.length - 1][0];
+  if (pad) {
+    // Must keep this in sync with dygraph-layout _evaluateLimits()
+    var range = right - left;
+    left -= range * pad;
+    right += range * pad;
+  }
   return [left, right];
 };
 
@@ -874,6 +886,7 @@ Dygraph.prototype.toPercentXCoord = function(x) {
  * @return { Integer } The number of columns.
  */
 Dygraph.prototype.numColumns = function() {
+  if (!this.rawData_) return 0;
   return this.rawData_[0] ? this.rawData_[0].length : this.attr_("labels").length;
 };
 
@@ -882,6 +895,7 @@ Dygraph.prototype.numColumns = function() {
  * @return { Integer } The number of rows, less any header.
  */
 Dygraph.prototype.numRows = function() {
+  if (!this.rawData_) return 0;
   return this.rawData_.length;
 };
 
@@ -893,11 +907,7 @@ Dygraph.prototype.numRows = function() {
  * @private
  */
 Dygraph.prototype.fullXRange_ = function() {
-  if (this.numRows() > 0) {
-    return [this.rawData_[0][0], this.rawData_[this.numRows() - 1][0]];
-  } else {
-    return [0, 1];
-  }
+  return this.xAxisExtremes();
 };
 
 /**
@@ -2540,35 +2550,71 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
           maxY = Math.max(extremeMaxY, maxY);
         }
       }
-      if (includeZero && minY > 0) minY = 0;
+
+      // Include zero if requested by the user.
+      if (includeZero && !logscale) {
+        if (minY > 0) minY = 0;
+        if (maxY < 0) maxY = 0;
+      }
 
       // Ensure we have a valid scale, otherwise default to [0, 1] for safety.
       if (minY == Infinity) minY = 0;
       if (maxY == -Infinity) maxY = 1;
 
-      // 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.
-      if (span === 0) { span = maxY; }
+      // special case: if we have no sense of scale, center on the sole value.
+      if (span === 0) {
+        if (maxY !== 0) {
+          span = Math.abs(maxY);
+        } else {
+          // ... and if the sole value is zero, use range 0-1.
+          maxY = 1;
+          span = 1;
+        }
+      }
+
+      // Add some padding. This supports two Y padding operation modes:
+      //
+      // - backwards compatible (yRangePad not set):
+      //   10% padding for automatic Y ranges, but not for user-supplied
+      //   ranges, and move a close-to-zero edge to zero except if
+      //   avoidMinZero is set, since drawing at the edge results in
+      //   invisible lines. Unfortunately lines drawn at the edge of a
+      //   user-supplied range will still be invisible. If logscale is
+      //   set, add a variable amount of padding at the top but
+      //   none at the bottom.
+      //
+      // - new-style (yRangePad set by the user):
+      //   always add the specified Y padding.
+      //
+      var ypadCompat = true;
+      var ypad = 0.1; // add 10%
+      if (this.attr_('yRangePad') !== null) {
+        ypadCompat = false;
+        // Convert pixel padding to ratio
+        ypad = this.attr_('yRangePad') / this.plotter_.area.h;
+      }
 
       var maxAxisY, minAxisY;
       if (logscale) {
-        maxAxisY = maxY + 0.1 * span;
-        minAxisY = minY;
+        if (ypadCompat) {
+          maxAxisY = maxY + ypad * span;
+          minAxisY = minY;
+        } else {
+          var logpad = Math.exp(Math.log(span) * ypad);
+          maxAxisY = maxY * logpad;
+          minAxisY = minY / logpad;
+        }
       } else {
-        maxAxisY = maxY + 0.1 * span;
-        minAxisY = minY - 0.1 * span;
+        maxAxisY = maxY + ypad * span;
+        minAxisY = minY - ypad * span;
 
-        // Try to include zero and make it minAxisY (or maxAxisY) if it makes sense.
-        if (!this.attr_("avoidMinZero")) {
+        // Backwards-compatible behavior: Move the span to start or end at zero if it's
+        // close to zero, but not if avoidMinZero is set.
+        if (ypadCompat && !this.attr_("avoidMinZero")) {
           if (minAxisY < 0 && minY >= 0) minAxisY = 0;
           if (maxAxisY > 0 && maxY <= 0) maxAxisY = 0;
         }
-
-        if (this.attr_("includeZero")) {
-          if (maxY < 0) maxAxisY = 0;
-          if (minY > 0) minAxisY = 0;
-        }
       }
       axis.extremeRange = [minAxisY, maxAxisY];
     }
@@ -2579,10 +2625,20 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
       axis.computedValueRange = [axis.valueWindow[0], axis.valueWindow[1]];
     } else if (axis.valueRange) {
       // This is a user-set value range for this axis.
-      axis.computedValueRange = [
-         isNullUndefinedOrNaN(axis.valueRange[0]) ? axis.extremeRange[0] : axis.valueRange[0],
-         isNullUndefinedOrNaN(axis.valueRange[1]) ? axis.extremeRange[1] : axis.valueRange[1]
-      ];
+      var y0 = isNullUndefinedOrNaN(axis.valueRange[0]) ? axis.extremeRange[0] : axis.valueRange[0];
+      var y1 = isNullUndefinedOrNaN(axis.valueRange[1]) ? axis.extremeRange[1] : axis.valueRange[1];
+      if (!ypadCompat) {
+        if (axis.logscale) {
+          var logpad = Math.exp(Math.log(span) * ypad);
+          y0 *= logpad;
+          y1 /= logpad;
+        } else {
+          var span = y1 - y0;
+          y0 -= span * ypad;
+          y1 += span * ypad;
+        }
+      }
+      axis.computedValueRange = [y0, y1];
     } else {
       axis.computedValueRange = axis.extremeRange;
     }
diff --git a/gallery/edge-padding.js b/gallery/edge-padding.js
new file mode 100644 (file)
index 0000000..deaa2fa
--- /dev/null
@@ -0,0 +1,81 @@
+Gallery.register(
+  'edge-padding',
+  {
+    name: 'Edge Padding',
+    title: 'Graph edge padding and axis position',
+    setup: function(parent) {
+      parent.innerHTML = (
+          "<p>" +
+          "  <b>Mode:</b>" +
+          "    <input type='radio' name='mode'>use {x,y}RangePad</input>" +
+          "    <input type='radio' name='mode'>original</input>" +
+          " <br /><b>Settings:</b>" +
+          "    <input type='checkbox' id='yrange'>valueRange=[-2,2]</input>" +
+          "</p>" +
+          "<div id='demodiv'></div>"
+          );
+    },
+    run: function() {
+      var parent = document.getElementById("demodiv");
+
+      var graphs = [];
+      var nrows = 50;
+
+      for (var oy = -2; oy <= 2; ++oy) {
+        for (var ox = -1; ox <= 1; ++ox) {
+          var gdiv = document.createElement('div');
+          gdiv.style.display = 'inline-block';
+          gdiv.style.margin = '2px';
+          parent.appendChild(gdiv);
+
+          var data = [];
+          for (var row = 0; row < nrows; ++row) {
+            var x = row * 5 / (nrows - 1);
+            data.push([ox * 2.5 + x - 2.5,
+                    oy + Math.sin(x),
+                    oy + Math.round(Math.cos(x))]);
+          }
+
+          var g = new Dygraph(gdiv, data, {
+              labels: ['x', 'A', 'B'],
+              labelDivWidth: 100,
+              gridLineColor: '#ccc',
+              includeZero: true,
+              width: 250,
+              height: 130
+          });
+          graphs.push(g);
+        }
+        parent.appendChild(document.createElement('br'));
+      }
+
+      var updateGraphOpts = function(opts) {
+        for (var i = 0; i < graphs.length; ++i) {
+          graphs[i].updateOptions(opts);
+        }
+      };
+
+      var mode = document.getElementsByName('mode');
+      mode[0].onchange = function() {
+        updateGraphOpts({
+          avoidMinZero: false,
+          xRangePad: 3,
+          yRangePad: 10,
+          drawAxesAtZero: true})};
+      mode[1].onchange = function() {
+        updateGraphOpts({
+          avoidMinZero: true,
+          xRangePad: 0,
+          yRangePad: null,
+          drawAxesAtZero: false})};
+      mode[0].checked = true;
+      mode[0].onchange();
+
+      var yrange = document.getElementById('yrange');
+      yrange.onchange = function(ev) {
+        updateGraphOpts({
+          valueRange: ev.target.checked ? [-2, 2] : null});
+      };
+
+    }
+  });
index d1da4a3..df899df 100644 (file)
@@ -161,3 +161,5 @@ a {
 #workarea #highlighted-series .few .dygraph-legend > span.highlight { border: 1px solid grey; }
 #workarea #highlighted-series .many .dygraph-legend > span { display: none; }
 #workarea #highlighted-series .many .dygraph-legend > span.highlight { display: inline; }
+
+#workarea #edge-padding fieldset { display: inline-block; vertical-align: top; }
index e1b32c1..9e661d4 100644 (file)
@@ -34,6 +34,7 @@
     <script src="temperature-sf-ny.js"></script>
     <script src="interaction.js"></script>
     <script src="linear-regression.js"></script>
+    <script src="edge-padding.js"></script>
 
     <!-- These might not remain in the gallery
     <script src="dygraph-simple.js"></script>