merge in upstream changes
authorDan Vanderkam <danvdk@gmail.com>
Sat, 16 Oct 2010 21:37:36 +0000 (17:37 -0400)
committerDan Vanderkam <danvdk@gmail.com>
Sat, 16 Oct 2010 21:37:36 +0000 (17:37 -0400)
NOTES [new file with mode: 0644]
dygraph-canvas.js
dygraph.js
tests/two-axes.html [new file with mode: 0644]

diff --git a/NOTES b/NOTES
new file mode 100644 (file)
index 0000000..211f59a
--- /dev/null
+++ b/NOTES
@@ -0,0 +1,37 @@
+Axis-related properties:
+includeZero
+valueRange
+labelsKMB
+labelsKMG2
+pixelsPerYLabel
+yAxisLabelWidth
+axisLabelFontSize
+axisTickSize
+
+How is the y-axis determined?
+
+Dygraph.numericTicks: min, max -> set of ticks for axis
+tick = { label: label, v: value }
+
+addYTicks_: min, max -> void
+sets the yAxis and yTicks properties of layout_
+
+drawGraph_:
+if set, uses this.valueRange_ ([low, high] array)
+  -> adds ticks via addYTicks_
+  -> sets displayedYRange_
+
+otherwise, calculates a good axis based on minY and maxY.
+
+this.displayedYRange_ is returned by the yAxisRange function.
+this is, in turn, used by the toDataCoords and toDomCoords methods.
+
+Path of least resistance:
+- in drawGraph_, calculate [minY, maxY] per-series
+- write a function to compute y-axes for all series, ensure only two axes.
+- make yAxis, yTicks into arrays in layout_
+- add a series -> axis mapping to layout_, dygraph
+- add code to Renderer to add second axis.
+- add optional 'series' parameter to toDomCoords/toDataCoords
+
+This won't be compatible with stacked charts.
index 817370d..7ee0039 100644 (file)
@@ -82,10 +82,13 @@ DygraphLayout.prototype._evaluateLimits = function() {
   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);
+  for (var i = 0; i < this.options.yAxes.length; i++) {
+    var axis = this.options.yAxes[i];
+    axis.minyval = axis.valueRange[0];
+    axis.maxyval = axis.valueRange[1];
+    axis.yrange = axis.maxyval - axis.minyval;
+    axis.yscale = (axis.yrange != 0 ? 1.0 / axis.yrange : 1.0);
+  }
 };
 
 DygraphLayout.prototype._evaluateLineCharts = function() {
@@ -95,12 +98,14 @@ DygraphLayout.prototype._evaluateLineCharts = function() {
     if (!this.datasets.hasOwnProperty(setName)) continue;
 
     var dataset = this.datasets[setName];
+    var axis = this.options.yAxes[this.options.seriesToAxisMap[setName]];
+
     for (var j = 0; j < dataset.length; j++) {
       var item = dataset[j];
       var point = {
         // TODO(danvk): here
         x: ((parseFloat(item[0]) - this.minxval) * this.xscale),
-        y: 1.0 - ((parseFloat(item[1]) - this.minyval) * this.yscale),
+        y: 1.0 - ((parseFloat(item[1]) - axis.minyval) * axis.yscale),
         xval: parseFloat(item[0]),
         yval: parseFloat(item[1]),
         name: setName
@@ -130,12 +135,15 @@ DygraphLayout.prototype._evaluateLineTicks = function() {
   }
 
   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]);
+  for (var i = 0; i < this.options.yAxes.length; i++ ) {
+    var axis = this.options.yAxes[i];
+    for (var j = 0; j < axis.ticks.length; j++) {
+      var tick = axis.ticks[j];
+      var label = tick.label;
+      var pos = 1.0 - (axis.yscale * (tick.v - axis.minyval));
+      if ((pos >= 0.0) && (pos <= 1.0)) {
+        this.yticks.push([i, pos, label]);
+      }
     }
   }
 };
@@ -284,7 +292,9 @@ DygraphCanvasRenderer = function(dygraph, element, layout, options) {
   this.ylabels = new Array();
   this.annotations = new Array();
 
+  // TODO(danvk): consider all axes in this computation.
   this.area = {
+    // TODO(danvk): per-axis setting.
     x: this.options.yAxisLabelWidth + 2 * this.options.axisTickSize,
     y: 0
   };
@@ -358,6 +368,15 @@ DygraphCanvasRenderer.isSupported = function(canvasName) {
  * Draw an X/Y grid on top of the existing plot
  */
 DygraphCanvasRenderer.prototype.render = function() {
+  // Shrink the drawing area to accomodate additional y-axes.
+  if (this.layout.options.yAxes.length == 2) {
+    // TODO(danvk): per-axis setting.
+    this.area.w -= (this.options.yAxisLabelWidth + 2 * this.options.axisTickSize);
+  } else if (this.layout.options.yAxes.length > 2) {
+    this.dygraph_.error("Only two y-axes are supported at this time. (Trying " +
+                        "to use " + this.layout.yAxes.length + ")");
+  }
+
   // Draw the new X/Y grid
   var ctx = this.element.getContext("2d");
 
@@ -371,8 +390,9 @@ DygraphCanvasRenderer.prototype.render = function() {
     ctx.strokeStyle = this.options.gridLineColor;
     ctx.lineWidth = this.options.axisLineWidth;
     for (var i = 0; i < ticks.length; i++) {
+      if (ticks[i][0] != 0) continue;  // TODO(danvk): per-axis property
       var x = this.area.x;
-      var y = this.area.y + ticks[i][0] * this.area.h;
+      var y = this.area.y + ticks[i][1] * this.area.h;
       ctx.beginPath();
       ctx.moveTo(x, y);
       ctx.lineTo(x + this.area.w, y);
@@ -387,7 +407,7 @@ DygraphCanvasRenderer.prototype.render = function() {
     ctx.strokeStyle = this.options.gridLineColor;
     ctx.lineWidth = this.options.axisLineWidth;
     for (var i=0; i<ticks.length; i++) {
-      var x = this.area.x + ticks[i][0] * this.area.w;
+      var x = this.area.x + ticks[i][1] * this.area.w;
       var y = this.area.y + this.area.h;
       ctx.beginPath();
       ctx.moveTo(x, y);
@@ -440,14 +460,17 @@ DygraphCanvasRenderer.prototype._renderAxis = function() {
         var tick = this.layout.yticks[i];
         if (typeof(tick) == "function") return;
         var x = this.area.x;
-        var y = this.area.y + tick[0] * this.area.h;
+        if (tick[0] == 1) {
+          x = this.area.x + this.area.w - labelStyle.width;
+        }
+        var y = this.area.y + tick[1] * this.area.h;
         context.beginPath();
         context.moveTo(x, y);
         context.lineTo(x - this.options.axisTickSize, y);
         context.closePath();
         context.stroke();
 
-        var label = makeDiv(tick[1]);
+        var label = makeDiv(tick[2]);
         var top = (y - this.options.axisLabelFontSize / 2);
         if (top < 0) top = 0;
 
@@ -456,8 +479,14 @@ DygraphCanvasRenderer.prototype._renderAxis = function() {
         } else {
           label.style.top = top + "px";
         }
-        label.style.left = "0px";
-        label.style.textAlign = "right";
+        if (tick[0] == 0) {
+          label.style.left = "0px";
+          label.style.textAlign = "right";
+        } else if (tick[0] == 1) {
+          label.style.left = (this.area.x + this.area.w +
+                              this.options.axisTickSize) + "px";
+          label.style.textAlign = "left";
+        }
         label.style.width = this.options.yAxisLabelWidth + "px";
         this.container.appendChild(label);
         this.ylabels.push(label);
@@ -671,6 +700,8 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
 
     for (var i = 0; i < setCount; i++) {
       var setName = setNames[i];
+      var axis = this.layout.options.yAxes[
+        this.layout.options.seriesToAxisMap[setName]];
       var color = this.colors[setName];
 
       // setup graphics context
@@ -678,7 +709,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       var prevX = NaN;
       var prevY = NaN;
       var prevYs = [-1, -1];
-      var yscale = this.layout.yscale;
+      var yscale = axis.yscale;
       // should be same color as the lines but only 15% opaque.
       var rgb = new RGBColor(color);
       var err_color = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' +
@@ -726,23 +757,24 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       ctx.fill();
     }
   } else if (fillGraph) {
-    var axisY = 1.0 + this.layout.minyval * this.layout.yscale;
-    if (axisY < 0.0) axisY = 0.0;
-    else if (axisY > 1.0) axisY = 1.0;
-    axisY = this.area.h * axisY + this.area.y;
-
     var baseline = []  // for stacked graphs: baseline for filling
 
     // process sets in reverse order (needed for stacked graphs)
     for (var i = setCount - 1; i >= 0; i--) {
       var setName = setNames[i];
       var color = this.colors[setName];
+      var axis = this.layout.options.yAxes[
+        this.layout.options.seriesToAxisMap[setName]];
+      var axisY = 1.0 + axis.minyval * axis.yscale;
+      if (axisY < 0.0) axisY = 0.0;
+      else if (axisY > 1.0) axisY = 1.0;
+      axisY = this.area.h * axisY + this.area.y;
 
       // setup graphics context
       ctx.save();
       var prevX = NaN;
       var prevYs = [-1, -1];
-      var yscale = this.layout.yscale;
+      var yscale = axis.yscale;
       // should be same color as the lines but only 15% opaque.
       var rgb = new RGBColor(color);
       var err_color = 'rgba(' + rgb.r + ',' + rgb.g + ',' + rgb.b + ',' +
index 89a461f..23d2bcc 100644 (file)
@@ -172,7 +172,6 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
   this.previousVerticalX_ = -1;
   this.fractions_ = attrs.fractions || false;
   this.dateWindow_ = attrs.dateWindow || null;
-  this.valueRange_ = attrs.valueRange || null;
   this.wilsonInterval_ = attrs.wilsonInterval || true;
   this.is_initial_draw_ = true;
   this.annotations_ = [];
@@ -1458,26 +1457,29 @@ Dygraph.dateTicker = function(startDate, endDate, self) {
  * @param {Number} startDate Start of the date window (millis since epoch)
  * @param {Number} endDate End of the date window (millis since epoch)
  * @param self
- * @param {function} formatter: Optional formatter to use for each tick value
+ * @param {function} attribute accessor function.
  * @return {Array.<Object>} Array of {label, value} tuples.
  * @public
  */
-Dygraph.numericTicks = function(minV, maxV, self, formatter) {
+Dygraph.numericTicks = function(minV, maxV, self, attr) {
+  // This is a bit of a hack to allow per-axis attributes.
+  if (!attr) attr = self.attr_;
+
   // Basic idea:
   // Try labels every 1, 2, 5, 10, 20, 50, 100, etc.
   // Calculate the resulting tick spacing (i.e. this.height_ / nTicks).
   // The first spacing greater than pixelsPerYLabel is what we use.
   // TODO(danvk): version that works on a log scale.
-  if (self.attr_("labelsKMG2")) {
+  if (attr("labelsKMG2")) {
     var mults = [1, 2, 4, 8];
   } else {
     var mults = [1, 2, 5];
   }
   var scale, low_val, high_val, nTicks;
   // TODO(danvk): make it possible to set this for x- and y-axes independently.
-  var pixelsPerTick = self.attr_('pixelsPerYLabel');
+  var pixelsPerTick = attr('pixelsPerYLabel');
   for (var i = -10; i < 50; i++) {
-    if (self.attr_("labelsKMG2")) {
+    if (attr("labelsKMG2")) {
       var base_scale = Math.pow(16, i);
     } else {
       var base_scale = Math.pow(10, i);
@@ -1498,11 +1500,11 @@ Dygraph.numericTicks = function(minV, maxV, self, formatter) {
   var ticks = [];
   var k;
   var k_labels = [];
-  if (self.attr_("labelsKMB")) {
+  if (attr("labelsKMB")) {
     k = 1000;
     k_labels = [ "K", "M", "B", "T" ];
   }
-  if (self.attr_("labelsKMG2")) {
+  if (attr("labelsKMG2")) {
     if (k) self.warn("Setting both labelsKMB and labelsKMG2. Pick one!");
     k = 1024;
     k_labels = [ "k", "M", "G", "T" ];
@@ -1614,10 +1616,13 @@ Dygraph.prototype.drawGraph_ = function(data) {
   var cumulative_y = [];  // For stacked series.
   var datasets = [];
 
+  var extremes = {};  // series name -> [low, high]
+
   // Loop over all fields and create datasets
   for (var i = data[0].length - 1; i >= 1; i--) {
     if (!this.visibility()[i - 1]) continue;
 
+    var seriesName = this.attr_("labels")[i];
     var connectSeparatedPoints = this.attr_('connectSeparatedPoints', i);
 
     var series = [];
@@ -1661,9 +1666,10 @@ Dygraph.prototype.drawGraph_ = function(data) {
       this.boundaryIds_[i-1] = [0, series.length-1];
     }
 
-    var extremes = this.extremeValues_(series);
-    var thisMinY = extremes[0];
-    var thisMaxY = extremes[1];
+    var seriesExtremes = this.extremeValues_(series);
+    extremes[seriesName] = seriesExtremes;
+    var thisMinY = seriesExtremes[0];
+    var thisMaxY = seriesExtremes[1];
     if (minY === null || (thisMinY != null && thisMinY < minY)) minY = thisMinY;
     if (maxY === null || (thisMaxY != null && thisMaxY > maxY)) maxY = thisMaxY;
 
@@ -1700,38 +1706,13 @@ Dygraph.prototype.drawGraph_ = function(data) {
     this.layout_.addDataset(this.attr_("labels")[i], datasets[i]);
   }
 
-  // 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) {
-      minY = 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.
-    if (span == 0) { span = maxY; }
-    var maxAxisY = maxY + 0.1 * span;
-    var minAxisY = minY - 0.1 * span;
-
-    // Try to include zero and make it minAxisY (or maxAxisY) if it makes sense.
-    if (!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;
-    }
-
-    this.addYTicks_(minAxisY, maxAxisY);
-    this.displayedYRange_ = [minAxisY, maxAxisY];
-  }
+  var out = this.computeYaxes_(extremes);
+  var axes = out[0];
+  var seriesToAxisMap = out[1];
+  this.displayedYRange_ = axes[0].valueRange;
+  this.layout_.updateOptions( { yAxes: axes,
+                                seriesToAxisMap: seriesToAxisMap
+                              } );
 
   this.addXTicks_();
 
@@ -1749,6 +1730,126 @@ Dygraph.prototype.drawGraph_ = function(data) {
 };
 
 /**
+ * Determine properties of the y axes. These include the number of axes and
+ * data series/styles associated with each. This does not compute the range of
+ * each axis, since that can only be determined when drawing.
+ * Returns [ axes, seriesToAxisMap ]
+ * axes = [ { options } ]
+ * seriesToAxisMap = { seriesName: 0, seriesName2: 1, ... }
+ *   indices are into the axes array.
+ */
+Dygraph.prototype.computeYaxes_ = function(extremes) {
+  var axes = [{}];  // always have at least one y-axis.
+  var seriesToAxisMap = {};
+  var seriesForAxis = [[]];
+
+  // all options which could be applied per-axis:
+  var axisOptions = [
+    'includeZero',
+    'valueRange',
+    'labelsKMB',
+    'labelsKMG2',
+    'pixelsPerYLabel',
+    'yAxisLabelWidth',
+    'axisLabelFontSize',
+    'axisTickSize'
+  ];
+
+  // Copy global axis options over to the first axis.
+  for (var i = 0; i < axisOptions.length; i++) {
+    var k = axisOptions[i];
+    var v = this.attr_(k);
+    if (v) axes[0][k] = v;
+  }
+
+  // Go through once and add all the axes.
+  for (var seriesName in extremes) {
+    if (!extremes.hasOwnProperty(seriesName)) continue;
+    var axis = this.attr_("axis", seriesName);
+    if (axis == null) {
+      seriesToAxisMap[seriesName] = 0;
+      seriesForAxis[0].push(seriesName);
+      continue;
+    }
+    if (typeof(axis) == 'object') {
+      // Add a new axis, making a copy of its per-axis options.
+      var opts = {};
+      Dygraph.update(opts, axes[0]);
+      Dygraph.update(opts, { valueRange: null });  // shouldn't inherit this.
+      Dygraph.update(opts, axis);
+      axes.push(opts);
+      seriesToAxisMap[seriesName] = axes.length - 1;
+      seriesForAxis.push([seriesName]);
+    }
+  }
+
+  // Go through one more time and assign series to an axis defined by another
+  // series, e.g. { 'Y1: { axis: {} }, 'Y2': { axis: 'Y1' } }
+  for (var seriesName in extremes) {
+    if (!extremes.hasOwnProperty(seriesName)) continue;
+    var axis = this.attr_("axis", seriesName);
+    if (typeof(axis) == 'string') {
+      if (!seriesToAxisMap.hasOwnProperty(axis)) {
+        this.error("Series " + seriesName + " wants to share a y-axis with " +
+                   "series " + axis + ", which does not define its own axis.");
+        return null;
+      }
+      var idx = seriesToAxisMap[axis];
+      seriesToAxisMap[seriesName] = idx;
+      seriesForAxis[idx].push(seriesName);
+    }
+  }
+
+  // Compute extreme values, a span and tick marks for each axis.
+  for (var i = 0; i < axes.length; i++) {
+    var axis = axes[i];
+    if (!axis.valueRange) {
+      // Calcuate the extremes of extremes.
+      var series = seriesForAxis[i];
+      var minY = Infinity;  // extremes[series[0]][0];
+      var maxY = -Infinity;  // extremes[series[0]][1];
+      for (var j = 0; j < series.length; j++) {
+        minY = Math.min(extremes[series[j]][0], minY);
+        maxY = Math.max(extremes[series[j]][1], maxY);
+      }
+      if (axis.includeZero && minY > 0) minY = 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.
+      if (span == 0) { span = maxY; }
+      var maxAxisY = maxY + 0.1 * span;
+      var minAxisY = minY - 0.1 * span;
+
+      // Try to include zero and make it minAxisY (or maxAxisY) if it makes sense.
+      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.valueRange = [minAxisY, maxAxisY];
+    }
+
+    // Add ticks.
+    axis.ticks =
+      Dygraph.numericTicks(axis.valueRange[0],
+                           axis.valueRange[1],
+                           this,
+                           function(self, axis) {
+                             return function(a) {
+                               if (axis.hasOwnProperty(a)) return axis[a];
+                               return self.attr_(a);
+                             };
+                           }(this, axis));
+  }
+
+  return [axes, seriesToAxisMap];
+};
+/**
  * Calculates the rolling average of a data set.
  * If originalData is [label, val], rolls the average of those.
  * If originalData is [label, [, it's interpreted as [value, stddev]
@@ -2333,9 +2434,6 @@ Dygraph.prototype.updateOptions = function(attrs) {
   if (attrs.dateWindow) {
     this.dateWindow_ = attrs.dateWindow;
   }
-  if (attrs.valueRange) {
-    this.valueRange_ = attrs.valueRange;
-  }
 
   // TODO(danvk): validate per-series options.
   // Supported:
@@ -2348,6 +2446,7 @@ Dygraph.prototype.updateOptions = function(attrs) {
   Dygraph.update(this.renderOptions_, attrs);
 
   this.labelsFromCSV_ = (this.attr_("labels") == null);
+  this.computeYaxes_();
 
   // TODO(danvk): this doesn't match the constructor logic
   this.layout_.updateOptions({ 'errorBars': this.attr_("errorBars") });
diff --git a/tests/two-axes.html b/tests/two-axes.html
new file mode 100644 (file)
index 0000000..bc03abc
--- /dev/null
@@ -0,0 +1,50 @@
+<html>
+  <head>
+    <title>Multiple y-axes</title>
+    <!--[if IE]>
+    <script type="text/javascript" src="excanvas.js"></script>
+    <![endif]-->
+    <script type="text/javascript" src="../strftime/strftime-min.js"></script>
+    <script type="text/javascript" src="../rgbcolor/rgbcolor.js"></script>
+    <script type="text/javascript" src="../dygraph-canvas.js"></script>
+    <script type="text/javascript" src="../dygraph.js"></script>
+  </head>
+  <body>
+    <h2>Multiple y-axes</h2>
+    <div id="demodiv"></div>
+    <script type="text/javascript">
+      var data = [];
+      for (var i = 1; i <= 100; i++) {
+        var m = "01", d = i;
+        if (d > 31) { m = "02"; d -= 31; }
+        if (m == "02" && d > 28) { m = "03"; d -= 28; }
+        if (m == "03" && d > 31) { m = "04"; d -= 31; }
+        if (d < 10) d = "0" + d;
+        // two series, one with range 1-100, one with range 1-2M
+        data.push([new Date("2010/" + m + "/" + d),
+                   i,
+                   100 - i,
+                   1e6 * (1 + i * (100 - i) / (50 * 50)),
+                   1e6 * (2 - i * (100 - i) / (50 * 50))]);
+      }
+      g = new Dygraph(
+              document.getElementById("demodiv"),
+              data,
+              {
+                labels: [ 'Date', 'Y1', 'Y2', 'Y3', 'Y4' ],
+                width: 640,
+                height: 350,
+                'Y3': {
+                  axis: {
+                    // set axis-related properties here
+                    labelsKMB: true
+                  }
+                },
+                'Y4': {
+                  axis: 'Y3'  // use the same y-axis as series Y3
+                }
+              }
+          );
+    </script>
+</body>
+</html>