Merge remote-tracking branch 'upstream/master' into rgbcolor_change
[dygraphs.git] / dygraph.js
index 045c394..72a459f 100644 (file)
  * whether the input data contains error ranges. For a complete list of
  * options, see http://dygraphs.com/options.html.
  */
-var Dygraph = function(div, data, opts) {
-  if (arguments.length > 0) {
-    if (arguments.length == 4) {
-      // Old versions of dygraphs took in the series labels as a constructor
-      // parameter. This doesn't make sense anymore, but it's easy to continue
-      // to support this usage.
-      this.warn("Using deprecated four-argument dygraph constructor");
-      this.__old_init__(div, data, arguments[2], arguments[3]);
-    } else {
-      this.__init__(div, data, opts);
-    }
+var Dygraph = function(div, data, opts, opt_fourth_param) {
+  if (opt_fourth_param !== undefined) {
+    // Old versions of dygraphs took in the series labels as a constructor
+    // parameter. This doesn't make sense anymore, but it's easy to continue
+    // to support this usage.
+    this.warn("Using deprecated four-argument dygraph constructor");
+    this.__old_init__(div, data, opts, opt_fourth_param);
+  } else {
+    this.__init__(div, data, opts);
   }
 };
 
@@ -93,7 +91,8 @@ Dygraph.DEFAULT_ROLL_PERIOD = 1;
 Dygraph.DEFAULT_WIDTH = 480;
 Dygraph.DEFAULT_HEIGHT = 320;
 
-Dygraph.ANIMATION_STEPS = 10;
+// For max 60 Hz. animation:
+Dygraph.ANIMATION_STEPS = 12;
 Dygraph.ANIMATION_DURATION = 200;
 
 // These are defined before DEFAULT_ATTRS so that it can refer to them.
@@ -182,6 +181,18 @@ Dygraph.dateAxisFormatter = function(date, granularity) {
   }
 };
 
+/**
+ * Standard plotters. These may be used by clients.
+ * Available plotters are:
+ * - Dygraph.Plotters.linePlotter: draws central lines (most common)
+ * - Dygraph.Plotters.errorPlotter: draws error bars
+ * - Dygraph.Plotters.fillPlotter: draws fills under lines (used with fillGraph)
+ *
+ * By default, the plotter is [fillPlotter, errorPlotter, linePlotter].
+ * This causes all the lines to be drawn over all the fills/error bars.
+ */
+Dygraph.Plotters = DygraphCanvasRenderer._Plotters;
+
 
 // Default attribute values.
 Dygraph.DEFAULT_ATTRS = {
@@ -235,6 +246,7 @@ Dygraph.DEFAULT_ATTRS = {
 
   stepPlot: false,
   avoidMinZero: false,
+  drawAxesAtZero: false,
 
   // Sizes of the various chart labels.
   titleHeight: 28,
@@ -262,6 +274,14 @@ Dygraph.DEFAULT_ATTRS = {
   rangeSelectorPlotStrokeColor: "#808FAB",
   rangeSelectorPlotFillColor: "#A7B1C4",
 
+  // The ordering here ensures that central lines always appear above any
+  // fill bars/error bars.
+  plotter: [
+    Dygraph.Plotters.fillPlotter,
+    Dygraph.Plotters.errorPlotter,
+    Dygraph.Plotters.linePlotter
+  ],
+
   // per-axis options
   axes: {
     x: {
@@ -290,6 +310,11 @@ Dygraph.DEFAULT_ATTRS = {
 Dygraph.HORIZONTAL = 1;
 Dygraph.VERTICAL = 2;
 
+// Installed plugins, in order of precedence (most-general to most-specific).
+// Plugins are installed after they are defined, in plugins/install.js.
+Dygraph.PLUGINS = [
+];
+
 // Used for initializing annotation CSS rules only once.
 Dygraph.addedAnnotationCSS = false;
 
@@ -331,6 +356,10 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
 
   attrs = Dygraph.mapLegacyOptions_(attrs);
 
+  if (typeof(div) == 'string') {
+    div = document.getElementById(div);
+  }
+
   if (!div) {
     Dygraph.error("Constructing dygraph with a non-existent div!");
     return;
@@ -384,6 +413,15 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
     // TODO(nikhilk): Add any other stackedGraph checks here.
   }
 
+  // These two options have a bad interaction. See issue 359.
+  if (attrs.showRangeSelector && attrs.animatedZooms) {
+    this.warn('You should not set animatedZooms=true when using the range selector.');
+    attrs.animatedZooms = false;
+  }
+
+  // DEPRECATION WARNING: All option processing should be moved from
+  // attrs_ and user_attrs_ to options_, which holds all this information.
+  //
   // Dygraphs has many options, some of which interact with one another.
   // To keep track of everything, we maintain two sets of options:
   //
@@ -404,23 +442,105 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
   this.setIndexByName_ = {};
   this.datasetIndex_ = [];
 
+  this.registeredEvents_ = [];
+  this.eventListeners_ = {};
+
+  this.attributes_ = new DygraphOptions(this);
+
   // Create the containing DIV and other interactive elements
   this.createInterface_();
 
+  // Activate plugins.
+  this.plugins_ = [];
+  for (var i = 0; i < Dygraph.PLUGINS.length; i++) {
+    var Plugin = Dygraph.PLUGINS[i];
+    var pluginInstance = new Plugin();
+    var pluginDict = {
+      plugin: pluginInstance,
+      events: {},
+      options: {},
+      pluginOptions: {}
+    };
+
+    var handlers = pluginInstance.activate(this);
+    for (var eventName in handlers) {
+      // TODO(danvk): validate eventName.
+      pluginDict.events[eventName] = handlers[eventName];
+    }
+
+    this.plugins_.push(pluginDict);
+  }
+
+  // At this point, plugins can no longer register event handlers.
+  // Construct a map from event -> ordered list of [callback, plugin].
+  for (var i = 0; i < this.plugins_.length; i++) {
+    var plugin_dict = this.plugins_[i];
+    for (var eventName in plugin_dict.events) {
+      if (!plugin_dict.events.hasOwnProperty(eventName)) continue;
+      var callback = plugin_dict.events[eventName];
+
+      var pair = [plugin_dict.plugin, callback];
+      if (!(eventName in this.eventListeners_)) {
+        this.eventListeners_[eventName] = [pair];
+      } else {
+        this.eventListeners_[eventName].push(pair);
+      }
+    }
+  }
+
   this.start_();
 };
 
 /**
+ * Triggers a cascade of events to the various plugins which are interested in them.
+ * Returns true if the "default behavior" should be performed, i.e. if none of
+ * the event listeners called event.preventDefault().
+ * @private
+ */
+Dygraph.prototype.cascadeEvents_ = function(name, extra_props) {
+  if (!(name in this.eventListeners_)) return true;
+
+  // QUESTION: can we use objects & prototypes to speed this up?
+  var e = {
+    dygraph: this,
+    cancelable: false,
+    defaultPrevented: false,
+    preventDefault: function() {
+      if (!e.cancelable) throw "Cannot call preventDefault on non-cancelable event.";
+      e.defaultPrevented = true;
+    },
+    propagationStopped: false,
+    stopPropagation: function() {
+      e.propagationStopped = true;
+    }
+  };
+  Dygraph.update(e, extra_props);
+
+  var callback_plugin_pairs = this.eventListeners_[name];
+  if (callback_plugin_pairs) {
+    for (var i = callback_plugin_pairs.length - 1; i >= 0; i--) {
+      var plugin = callback_plugin_pairs[i][0];
+      var callback = callback_plugin_pairs[i][1];
+      callback.call(plugin, e);
+      if (e.propagationStopped) break;
+    }
+  }
+  return e.defaultPrevented;
+};
+
+/**
  * 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).
+ * 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 === null || axis === undefined) {
+    return this.zoomed_x_ || this.zoomed_y_;
+  }
   if (axis === 'x') return this.zoomed_x_;
   if (axis === 'y') return this.zoomed_y_;
   throw "axis parameter is [" + axis + "] must be null, 'x' or 'y'.";
@@ -457,31 +577,25 @@ Dygraph.prototype.attr_ = function(name, seriesName) {
     Dygraph.OPTIONS_REFERENCE[name] = true;
   }
 // </REMOVE_FOR_COMBINED>
+  return seriesName ? this.attributes_.getForSeries(name, seriesName) : this.attributes_.get(name);
+};
 
-  var sources = [];
-  sources.push(this.attrs_);
-  if (this.user_attrs_) {
-    sources.push(this.user_attrs_);
-    if (seriesName) {
-      if (this.user_attrs_.hasOwnProperty(seriesName)) {
-        sources.push(this.user_attrs_[seriesName]);
-      }
-      if (seriesName === this.highlightSet_ &&
-          this.user_attrs_.hasOwnProperty('highlightSeriesOpts')) {
-        sources.push(this.user_attrs_['highlightSeriesOpts']);
-      }
-    }
-  }
-
-  var ret = null;
-  for (var i = sources.length - 1; i >= 0; --i) {
-    var source = sources[i];
-    if (source.hasOwnProperty(name)) {
-      ret = source[name];
-      break;
-    }
-  }
-  return ret;
+/**
+ * Returns the current value for an option, as set in the constructor or via
+ * updateOptions. You may pass in an (optional) series name to get per-series
+ * values for the option.
+ *
+ * All values returned by this method should be considered immutable. If you
+ * modify them, there is no guarantee that the changes will be honored or that
+ * dygraphs will remain in a consistent state. If you want to modify an option,
+ * use updateOptions() instead.
+ *
+ * @param { String } name The name of the option (e.g. 'strokeWidth')
+ * @param { String } [opt_seriesName] Series name to get per-series values.
+ * @return { ... } The value of the option.
+ */
+Dygraph.prototype.getOption = function(name, opt_seriesName) {
+  return this.attr_(name, opt_seriesName);
 };
 
 /**
@@ -831,7 +945,6 @@ Dygraph.prototype.createInterface_ = function() {
   if (this.attr_('showRangeSelector')) {
     // The range selector must be created here so that its canvases and contexts get created here.
     // For some reason, if the canvases and contexts don't get created here, things don't work in IE.
-    // The range selector also sets xAxisHeight in order to reserve space.
     this.rangeSelector_ = new DygraphRangeSelector(this);
   }
 
@@ -849,18 +962,17 @@ Dygraph.prototype.createInterface_ = function() {
   }
 
   var dygraph = this;
-  
+
   this.mouseMoveHandler = function(e) {
-         dygraph.mouseMove_(e);
+    dygraph.mouseMove_(e);
   };
-  Dygraph.addEvent(this.mouseEventElement_, 'mousemove', this.mouseMoveHandler);
-  
+  this.addEvent(this.mouseEventElement_, 'mousemove', this.mouseMoveHandler);
+
   this.mouseOutHandler = function(e) {
-         dygraph.mouseOut_(e);
+    dygraph.mouseOut_(e);
   };
-  Dygraph.addEvent(this.mouseEventElement_, 'mouseout', this.mouseOutHandler);
+  this.addEvent(this.mouseEventElement_, 'mouseout', this.mouseOutHandler);
 
-  this.createStatusMessage_();
   this.createDragInterface_();
 
   this.resizeHandler = function(e) {
@@ -869,7 +981,7 @@ Dygraph.prototype.createInterface_ = function() {
 
   // Update when the window is resized.
   // TODO(danvk): drop frames depending on complexity of the chart.
-  Dygraph.addEvent(window, 'resize', this.resizeHandler);
+  this.addEvent(window, 'resize', this.resizeHandler);
 };
 
 /**
@@ -884,10 +996,17 @@ Dygraph.prototype.destroy = function() {
       node.removeChild(node.firstChild);
     }
   };
-  
-  // remove mouse event handlers
+
+  for (var idx = 0; idx < this.registeredEvents_.length; idx++) {
+    var reg = this.registeredEvents_[idx];
+    Dygraph.removeEvent(reg.elem, reg.type, reg.fn);
+  }
+  this.registeredEvents_ = [];
+
+  // remove mouse event handlers (This may not be necessary anymore)
   Dygraph.removeEvent(this.mouseEventElement_, 'mouseout', this.mouseOutHandler);
   Dygraph.removeEvent(this.mouseEventElement_, 'mousemove', this.mouseMoveHandler);
+  Dygraph.removeEvent(this.mouseEventElement_, 'mousemove', this.mouseUpHandler_);
   removeRecursive(this.maindiv_);
 
   var nullOut = function(obj) {
@@ -957,8 +1076,10 @@ Dygraph.prototype.createMouseEventElement_ = function() {
  * @private
  */
 Dygraph.prototype.setColors_ = function() {
-  var num = this.attr_("labels").length - 1;
+  var labels = this.getLabels();
+  var num = labels.length - 1;
   this.colors_ = [];
+  this.colorsMap_ = {};
   var colors = this.attr_('colors');
   var i;
   if (!colors) {
@@ -970,22 +1091,24 @@ Dygraph.prototype.setColors_ = function() {
       // alternate colors for high contrast.
       var idx = i % 2 ? Math.ceil(i / 2) : (half + i / 2);
       var hue = (1.0 * idx/ (1 + num));
-      this.colors_.push(Dygraph.hsvToRGB(hue, sat, val));
+      var colorStr = Dygraph.hsvToRGB(hue, sat, val);
+      this.colors_.push(colorStr);
+      this.colorsMap_[labels[i]] = colorStr;
     }
   } else {
     for (i = 0; i < num; i++) {
       if (!this.visibility()[i]) continue;
       var colorStr = colors[i % colors.length];
       this.colors_.push(colorStr);
+      this.colorsMap_[labels[1 + i]] = colorStr;
     }
   }
-
-  this.plotter_.setColors(this.colors_);
 };
 
 /**
  * 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.
+ * This does not return colors for invisible series.
  * @return {Array<string>} The list of colors.
  */
 Dygraph.prototype.getColors = function() {
@@ -993,60 +1116,32 @@ Dygraph.prototype.getColors = function() {
 };
 
 /**
- * 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
- * been specified.
- * @private
+ * Returns a few attributes of a series, i.e. its color, its visibility, which
+ * axis it's assigned to, and its column in the original data.
+ * Returns null if the series does not exist.
+ * Otherwise, returns an object with column, visibility, color and axis properties.
+ * The "axis" property will be set to 1 for y1 and 2 for y2.
+ * The "column" property can be fed back into getValue(row, column) to get
+ * values for this series.
  */
-Dygraph.prototype.createStatusMessage_ = function() {
-  var userLabelsDiv = this.user_attrs_.labelsDiv;
-  if (userLabelsDiv && null !== userLabelsDiv &&
-      (typeof(userLabelsDiv) == "string" || userLabelsDiv instanceof String)) {
-    this.user_attrs_.labelsDiv = document.getElementById(userLabelsDiv);
-  }
-  if (!this.attr_("labelsDiv")) {
-    var divWidth = this.attr_('labelsDivWidth');
-    var messagestyle = {
-      "position": "absolute",
-      "fontSize": "14px",
-      "zIndex": 10,
-      "width": divWidth + "px",
-      "top": "0px",
-      "left": (this.width_ - divWidth - 2) + "px",
-      "background": "white",
-      "textAlign": "left",
-      "overflow": "hidden"};
-    Dygraph.update(messagestyle, this.attr_('labelsDivStyles'));
-    var div = document.createElement("div");
-    div.className = "dygraph-legend";
-    for (var name in messagestyle) {
-      if (messagestyle.hasOwnProperty(name)) {
-        try {
-          div.style[name] = messagestyle[name];
-        } catch (e) {
-          this.warn("You are using unsupported css properties for your browser in labelsDivStyles");
-        }
-      }
+Dygraph.prototype.getPropertiesForSeries = function(series_name) {
+  var idx = -1;
+  var labels = this.getLabels();
+  for (var i = 1; i < labels.length; i++) {
+    if (labels[i] == series_name) {
+      idx = i;
+      break;
     }
-    this.graphDiv.appendChild(div);
-    this.attrs_.labelsDiv = div;
   }
-};
-
-/**
- * Position the labels div so that:
- * - its right edge is flush with the right edge of the charting area
- * - its top edge is flush with the top edge of the charting area
- * @private
- */
-Dygraph.prototype.positionLabelsDiv_ = function() {
-  // Don't touch a user-specified labelsDiv.
-  if (this.user_attrs_.hasOwnProperty("labelsDiv")) return;
+  if (idx == -1) return null;
 
-  var area = this.plotter_.area;
-  var div = this.attr_("labelsDiv");
-  div.style.left = area.x + area.w - this.attr_("labelsDivWidth") - 1 + "px";
-  div.style.top = area.y + "px";
+  return {
+    name: series_name,
+    column: idx,
+    visible: this.visibility()[idx - 1],
+    color: this.colorsMap_[series_name],
+    axis: 1 + this.attributes_.axisForSeries(series_name)
+  };
 };
 
 /**
@@ -1120,6 +1215,7 @@ Dygraph.prototype.createDragInterface_ = function() {
     prevEndX: null, // pixel coordinates
     prevEndY: null, // pixel coordinates
     prevDragDirection: null,
+    cancelNextDblclick: false,  // see comment in dygraph-interaction-model.js
 
     // The value on the left side of the graph when a pan operation starts.
     initialLeftmostDate: null,
@@ -1143,7 +1239,12 @@ Dygraph.prototype.createDragInterface_ = function() {
     boundedDates: null, // [minDate, maxDate]
     boundedValues: null, // [[minValue, maxValue] ...]
 
-    initializeMouseDown: function(event, g, context) {
+    // We cover iframes during mouse interactions. See comments in
+    // dygraph-utils.js for more info on why this is a good idea.
+    tarp: new Dygraph.IFrameTarp(),
+
+    // contextB is the same thing as this context object but renamed.
+    initializeMouseDown: function(event, g, contextB) {
       // prevents mouse drags from selecting page text.
       if (event.preventDefault) {
         event.preventDefault();  // Firefox, Chrome, etc.
@@ -1152,10 +1253,12 @@ Dygraph.prototype.createDragInterface_ = function() {
         event.cancelBubble = true;
       }
 
-      context.px = Dygraph.findPosX(g.canvas_);
-      context.py = Dygraph.findPosY(g.canvas_);
-      context.dragStartX = g.dragGetX_(event, context);
-      context.dragStartY = g.dragGetY_(event, context);
+      contextB.px = Dygraph.findPosX(g.canvas_);
+      contextB.py = Dygraph.findPosY(g.canvas_);
+      contextB.dragStartX = g.dragGetX_(event, contextB);
+      contextB.dragStartY = g.dragGetY_(event, contextB);
+      contextB.cancelNextDblclick = false;
+      contextB.tarp.cover();
     }
   };
 
@@ -1173,13 +1276,13 @@ Dygraph.prototype.createDragInterface_ = function() {
 
   for (var eventName in interactionModel) {
     if (!interactionModel.hasOwnProperty(eventName)) continue;
-    Dygraph.addEvent(this.mouseEventElement_, eventName,
+    this.addEvent(this.mouseEventElement_, eventName,
         bindHandler(interactionModel[eventName]));
   }
 
   // If the user releases the mouse button during a drag, but not over the
   // canvas, then it doesn't count as a zooming action.
-  Dygraph.addEvent(document, 'mouseup', function(event) {
+  this.mouseUpHandler_ = function(event) {
     if (context.isZooming || context.isPanning) {
       context.isZooming = false;
       context.dragStartX = null;
@@ -1195,7 +1298,11 @@ Dygraph.prototype.createDragInterface_ = function() {
         delete self.axes_[i].dragValueRange;
       }
     }
-  });
+
+    context.tarp.uncover();
+  };
+
+  this.addEvent(document, 'mouseup', this.mouseUpHandler_);
 };
 
 /**
@@ -1415,7 +1522,9 @@ Dygraph.prototype.doUnzoom_ = function() {
       newValueRanges = [];
       for (i = 0; i < this.axes_.length; i++) {
         var axis = this.axes_[i];
-        newValueRanges.push(axis.valueRange != null ? axis.valueRange : axis.extremeRange);
+        newValueRanges.push((axis.valueRange !== null &&
+                             axis.valueRange !== undefined) ?
+                            axis.valueRange : axis.extremeRange);
       }
     }
 
@@ -1510,19 +1619,25 @@ Dygraph.prototype.eventToDomCoords = function(event) {
  */
 Dygraph.prototype.findClosestRow = function(domX) {
   var minDistX = Infinity;
-  var idx = -1;
-  var points = this.layout_.points;
-  var l = points.length;
-  for (var i = 0; i < l; i++) {
-    var point = points[i];
-    if (!Dygraph.isValidPoint(point, true)) continue;
-    var dist = Math.abs(point.canvasx - domX);
-    if (dist < minDistX) {
-      minDistX = dist;
-      idx = i;
+  var pointIdx = -1, setIdx = -1;
+  var sets = this.layout_.points;
+  for (var i = 0; i < sets.length; i++) {
+    var points = sets[i];
+    var len = points.length;
+    for (var j = 0; j < len; j++) {
+      var point = points[j];
+      if (!Dygraph.isValidPoint(point, true)) continue;
+      var dist = Math.abs(point.canvasx - domX);
+      if (dist < minDistX) {
+        minDistX = dist;
+        setIdx = i;
+        pointIdx = j;
+      }
     }
   }
-  return this.idxToRow_(idx);
+
+  // TODO(danvk): remove this function; it's trivial and has only one use.
+  return this.idxToRow_(setIdx, pointIdx);
 };
 
 /**
@@ -1540,13 +1655,11 @@ Dygraph.prototype.findClosestRow = function(domX) {
 Dygraph.prototype.findClosestPoint = function(domX, domY) {
   var minDist = Infinity;
   var idx = -1;
-  var points = this.layout_.points;
   var dist, dx, dy, point, closestPoint, closestSeries;
-  for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
-    var first = this.layout_.setPointsOffsets[setIdx];
-    var len = this.layout_.setPointsLengths[setIdx];
-    for (var i = 0; i < len; ++i) {
-      var point = points[first + i];
+  for ( var setIdx = this.layout_.datasets.length - 1 ; setIdx >= 0 ; --setIdx ) {
+    var points = this.layout_.points[setIdx];
+    for (var i = 0; i < points.length; ++i) {
+      var point = points[i];
       if (!Dygraph.isValidPoint(point)) continue;
       dx = point.canvasx - domX;
       dy = point.canvasy - domY;
@@ -1583,18 +1696,17 @@ Dygraph.prototype.findStackedPoint = function(domX, domY) {
   var row = this.findClosestRow(domX);
   var boundary = this.getLeftBoundary_();
   var rowIdx = row - boundary;
-  var points = this.layout_.points;
+  var sets = this.layout_.points;
   var closestPoint, closestSeries;
   for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
-    var first = this.layout_.setPointsOffsets[setIdx];
-    var len = this.layout_.setPointsLengths[setIdx];
-    if (rowIdx >= len) continue;
-    var p1 = points[first + rowIdx];
+    var points = this.layout_.points[setIdx];
+    if (rowIdx >= points.length) continue;
+    var p1 = points[rowIdx];
     if (!Dygraph.isValidPoint(p1)) continue;
     var py = p1.canvasy;
-    if (domX > p1.canvasx && rowIdx + 1 < len) {
+    if (domX > p1.canvasx && rowIdx + 1 < points.length) {
       // interpolate series Y value using next point
-      var p2 = points[first + rowIdx + 1];
+      var p2 = points[rowIdx + 1];
       if (Dygraph.isValidPoint(p2)) {
         var dx = p2.canvasx - p1.canvasx;
         if (dx > 0) {
@@ -1604,7 +1716,7 @@ Dygraph.prototype.findStackedPoint = function(domX, domY) {
       }
     } else if (domX < p1.canvasx && rowIdx > 0) {
       // interpolate series Y value using previous point
-      var p0 = points[first + rowIdx - 1];
+      var p0 = points[rowIdx - 1];
       if (Dygraph.isValidPoint(p0)) {
         var dx = p1.canvasx - p0.canvasx;
         if (dx > 0) {
@@ -1614,7 +1726,7 @@ Dygraph.prototype.findStackedPoint = function(domX, domY) {
       }
     }
     // Stop if the point (domX, py) is above this series' upper edge
-    if (setIdx == 0 || py < domY) {
+    if (setIdx === 0 || py < domY) {
       closestPoint = p1;
       closestSeries = setIdx;
     }
@@ -1637,7 +1749,7 @@ Dygraph.prototype.findStackedPoint = function(domX, domY) {
 Dygraph.prototype.mouseMove_ = function(event) {
   // This prevents JS errors when mousing over the canvas before data loads.
   var points = this.layout_.points;
-  if (points === undefined) return;
+  if (points === undefined || points === null) return;
 
   var canvasCoords = this.eventToDomCoords(event);
   var canvasx = canvasCoords[0];
@@ -1645,7 +1757,7 @@ Dygraph.prototype.mouseMove_ = function(event) {
 
   var highlightSeriesOpts = this.attr_("highlightSeriesOpts");
   var selectionChanged = false;
-  if (highlightSeriesOpts) {
+  if (highlightSeriesOpts && !this.isSeriesLocked()) {
     var closest;
     if (this.attr_("stackedGraph")) {
       closest = this.findStackedPoint(canvasx, canvasy);
@@ -1666,6 +1778,7 @@ Dygraph.prototype.mouseMove_ = function(event) {
 
 /**
  * Fetch left offset from first defined boundaryIds record (see bug #236).
+ * @private
  */
 Dygraph.prototype.getLeftBoundary_ = function() {
   for (var i = 0; i < this.boundaryIds_.length; i++) {
@@ -1682,183 +1795,19 @@ Dygraph.prototype.getLeftBoundary_ = function() {
  * @return int row number, or -1 if none could be found.
  * @private
  */
-Dygraph.prototype.idxToRow_ = function(idx) {
-  if (idx < 0) return -1;
+Dygraph.prototype.idxToRow_ = function(setIdx, rowIdx) {
+  if (rowIdx < 0) return -1;
 
   var boundary = this.getLeftBoundary_();
-  for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
-    var set = this.layout_.datasets[setIdx];
-    if (idx < set.length) {
-      return boundary + idx;
-    }
-    idx -= set.length;
-  }
-  return -1;
-};
-
-/**
- * @private
- * Generates legend html dash for any stroke pattern. It will try to scale the
- * pattern to fit in 1em width. Or if small enough repeat the partern for 1em
- * width.
- * @param strokePattern The pattern
- * @param color The color of the series.
- * @param oneEmWidth The width in pixels of 1em in the legend.
- */
-Dygraph.prototype.generateLegendDashHTML_ = function(strokePattern, color, oneEmWidth) {
-  var dash = "";
-  var i, j, paddingLeft, marginRight;
-  var strokePixelLength = 0, segmentLoop = 0;
-  var normalizedPattern = [];
-  var loop;
-  // IE 7,8 fail at these divs, so they get boring legend, have not tested 9.
-  var isIE = (/MSIE/.test(navigator.userAgent) && !window.opera);
-  if(isIE) {
-    return "&mdash;";
-  }
-  if (!strokePattern || strokePattern.length <= 1) {
-    // Solid line
-    dash = "<div style=\"display: inline-block; position: relative; " +
-    "bottom: .5ex; padding-left: 1em; height: 1px; " +
-    "border-bottom: 2px solid " + color + ";\"></div>";
-  } else {
-    // Compute the length of the pixels including the first segment twice, 
-    // since we repeat it.
-    for (i = 0; i <= strokePattern.length; i++) {
-      strokePixelLength += strokePattern[i%strokePattern.length];
-    }
-
-    // See if we can loop the pattern by itself at least twice.
-    loop = Math.floor(oneEmWidth/(strokePixelLength-strokePattern[0]));
-    if (loop > 1) {
-      // This pattern fits at least two times, no scaling just convert to em;
-      for (i = 0; i < strokePattern.length; i++) {
-        normalizedPattern[i] = strokePattern[i]/oneEmWidth;
-      }
-      // Since we are repeating the pattern, we don't worry about repeating the
-      // first segment in one draw.
-      segmentLoop = normalizedPattern.length;
-    } else {
-      // If the pattern doesn't fit in the legend we scale it to fit.
-      loop = 1;
-      for (i = 0; i < strokePattern.length; i++) {
-        normalizedPattern[i] = strokePattern[i]/strokePixelLength;
-      }
-      // For the scaled patterns we do redraw the first segment.
-      segmentLoop = normalizedPattern.length+1;
-    }
-    // Now make the pattern.
-    for (j = 0; j < loop; j++) {
-      for (i = 0; i < segmentLoop; i+=2) {
-        // The padding is the drawn segment.
-        paddingLeft = normalizedPattern[i%normalizedPattern.length];
-        if (i < strokePattern.length) {
-          // The margin is the space segment.
-          marginRight = normalizedPattern[(i+1)%normalizedPattern.length];
-        } else {
-          // The repeated first segment has no right margin.
-          marginRight = 0;
-        }
-        dash += "<div style=\"display: inline-block; position: relative; " +
-          "bottom: .5ex; margin-right: " + marginRight + "em; padding-left: " +
-          paddingLeft + "em; height: 1px; border-bottom: 2px solid " + color +
-          ";\"></div>";
-      }
-    }
-  }
-  return dash;
-};
-
-/**
- * @private
- * Generates HTML for the legend which is displayed when hovering over the
- * chart. If no selected points are specified, a default legend is returned
- * (this may just be the empty string).
- * @param { Number } [x] The x-value of the selected points.
- * @param { [Object] } [sel_points] List of selected points for the given
- * x-value. Should have properties like 'name', 'yval' and 'canvasy'.
- * @param { Number } [oneEmWidth] The pixel width for 1em in the legend.
- */
-Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) {
-  // If no points are selected, we display a default legend. Traditionally,
-  // this has been blank. But a better default would be a conventional legend,
-  // which provides essential information for a non-interactive chart.
-  var html, sepLines, i, c, dash, strokePattern;
-  if (typeof(x) === 'undefined') {
-    if (this.attr_('legend') != 'always') return '';
-
-    sepLines = this.attr_('labelsSeparateLines');
-    var labels = this.attr_('labels');
-    html = '';
-    for (i = 1; i < labels.length; i++) {
-      if (!this.visibility()[i - 1]) continue;
-      c = this.plotter_.colors[labels[i]];
-      if (html !== '') html += (sepLines ? '<br/>' : ' ');
-      strokePattern = this.attr_("strokePattern", labels[i]);
-      dash = this.generateLegendDashHTML_(strokePattern, c, oneEmWidth);
-      html += "<span style='font-weight: bold; color: " + c + ";'>" + dash +
-        " " + labels[i] + "</span>";
-    }
-    return html;
-  }
-
-  var xOptView = this.optionsViewForAxis_('x');
-  var xvf = xOptView('valueFormatter');
-  html = xvf(x, xOptView, this.attr_('labels')[0], this) + ":";
-
-  var yOptViews = [];
-  var num_axes = this.numAxes();
-  for (i = 0; i < num_axes; i++) {
-    yOptViews[i] = this.optionsViewForAxis_('y' + (i ? 1 + i : ''));
-  }
-  var showZeros = this.attr_("labelsShowZeroValues");
-  sepLines = this.attr_("labelsSeparateLines");
-  for (i = 0; i < this.selPoints_.length; i++) {
-    var pt = this.selPoints_[i];
-    if (pt.yval === 0 && !showZeros) continue;
-    if (!Dygraph.isOK(pt.canvasy)) continue;
-    if (sepLines) html += "<br/>";
-
-    var yOptView = yOptViews[this.seriesToAxisMap_[pt.name]];
-    var fmtFunc = yOptView('valueFormatter');
-    c = this.plotter_.colors[pt.name];
-    var yval = fmtFunc(pt.yval, yOptView, pt.name, this);
-
-    var cls = (pt.name == this.highlightSet_) ? " class='highlight'" : "";
-    // TODO(danvk): use a template string here and make it an attribute.
-    html += "<span" + cls + ">" + " <b><span style='color: " + c + ";'>" + pt.name +
-        "</span></b>:" + yval + "</span>";
-  }
-  return html;
-};
-
-/**
- * @private
- * Displays information about the selected points in the legend. If there is no
- * selection, the legend will be cleared.
- * @param { Number } [x] The x-value of the selected points.
- * @param { [Object] } [sel_points] List of selected points for the given
- * x-value. Should have properties like 'name', 'yval' and 'canvasy'.
- */
-Dygraph.prototype.setLegendHTML_ = function(x, sel_points) {
-  var labelsDiv = this.attr_("labelsDiv");
-  if (!labelsDiv) return;
-
-  var sizeSpan = document.createElement('span');
-  // Calculates the width of 1em in pixels for the legend.
-  sizeSpan.setAttribute('style', 'margin: 0; padding: 0 0 0 1em; border: 0;');
-  labelsDiv.appendChild(sizeSpan);
-  var oneEmWidth=sizeSpan.offsetWidth;
-
-  var html = this.generateLegendHTML_(x, sel_points, oneEmWidth);
-  if (labelsDiv !== null) {
-    labelsDiv.innerHTML = html;
-  } else {
-    if (typeof(this.shown_legend_error_) == 'undefined') {
-      this.error('labelsDiv is set to something nonexistent; legend will not be shown.');
-      this.shown_legend_error_ = true;
-    }
-  }
+  return boundary + rowIdx;
+  // for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
+  //   var set = this.layout_.datasets[setIdx];
+  //   if (idx < set.length) {
+  //     return boundary + idx;
+  //   }
+  //   idx -= set.length;
+  // }
+  // return -1;
 };
 
 Dygraph.prototype.animateSelection_ = function(direction) {
@@ -1898,6 +1847,12 @@ Dygraph.prototype.animateSelection_ = function(direction) {
  * @private
  */
 Dygraph.prototype.updateSelection_ = function(opt_animFraction) {
+  var defaultPrevented = this.cascadeEvents_('select', {
+    selectedX: this.lastx_,
+    selectedPoints: this.selPoints_
+  });
+  // TODO(danvk): use defaultPrevented here?
+
   // Clear the previously drawn vertical, if there is one
   var i;
   var ctx = this.canvas_ctx_;
@@ -1920,8 +1875,10 @@ Dygraph.prototype.updateSelection_ = function(opt_animFraction) {
       ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')';
       ctx.fillRect(0, 0, this.width_, this.height_);
     }
-    var setIdx = this.datasetIndexFromSetName_(this.highlightSet_);
-    this.plotter_._drawLine(ctx, setIdx);
+
+    // Redraw only the highlighted series in the interactive canvas (not the
+    // static plot canvas, which is where series are usually drawn).
+    this.plotter_._renderLineChart(this.highlightSet_, ctx);
   } else if (this.previousVerticalX_ >= 0) {
     // Determine the maximum highlight circle size.
     var maxCircleSize = 0;
@@ -1940,11 +1897,6 @@ Dygraph.prototype.updateSelection_ = function(opt_animFraction) {
   }
 
   if (this.selPoints_.length > 0) {
-    // Set the status message to indicate the selected point(s)
-    if (this.attr_('showLabelsOnHighlight')) {
-      this.setLegendHTML_(this.lastx_, this.selPoints_);
-    }
-
     // Draw colored circles over the center of each selected point
     var canvasx = this.selPoints_[0].canvasx;
     ctx.save();
@@ -1978,11 +1930,13 @@ Dygraph.prototype.updateSelection_ = function(opt_animFraction) {
  * hover dots on the chart). Set to false to clear any selection.
  * @param { seriesName } optional series name to highlight that series with the
  * the highlightSeriesOpts setting.
+ * @param { locked } optional If true, keep seriesName selected when mousing
+ * over the graph, disabling closest-series highlighting. Call clearSelection()
+ * to unlock it.
  */
-Dygraph.prototype.setSelection = function(row, opt_seriesName) {
+Dygraph.prototype.setSelection = function(row, opt_seriesName, opt_locked) {
   // Extract the points we've selected
   this.selPoints_ = [];
-  var pos = 0;
 
   if (row !== false) {
     row -= this.getLeftBoundary_();
@@ -1995,15 +1949,14 @@ Dygraph.prototype.setSelection = function(row, opt_seriesName) {
     for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
       var set = this.layout_.datasets[setIdx];
       if (row < set.length) {
-        var point = this.layout_.points[pos+row];
+        var point = this.layout_.points[setIdx][row];
 
         if (this.attr_("stackedGraph")) {
-          point = this.layout_.unstackPointAtIndex(pos+row);
+          point = this.layout_.unstackPointAtIndex(setIdx, row);
         }
 
-        if (!(point.yval === null)) this.selPoints_.push(point);
+        if (point.yval !== null) this.selPoints_.push(point);
       }
-      pos += set.length;
     }
   } else {
     if (this.lastRow_ >= 0) changed = true;
@@ -2021,6 +1974,10 @@ Dygraph.prototype.setSelection = function(row, opt_seriesName) {
     this.highlightSet_ = opt_seriesName;
   }
 
+  if (opt_locked !== undefined) {
+    this.lockedSet_ = opt_locked;
+  }
+
   if (changed) {
     this.updateSelection_(undefined);
   }
@@ -2037,7 +1994,7 @@ Dygraph.prototype.mouseOut_ = function(event) {
     this.attr_("unhighlightCallback")(event);
   }
 
-  if (this.attr_("hideOverlayOnMouseOut")) {
+  if (this.attr_("hideOverlayOnMouseOut") && !this.lockedSet_) {
     this.clearSelection();
   }
 };
@@ -2047,6 +2004,9 @@ Dygraph.prototype.mouseOut_ = function(event) {
  * the mouse over the chart).
  */
 Dygraph.prototype.clearSelection = function() {
+  this.cascadeEvents_('deselect', {});
+
+  this.lockedSet_ = false;
   // Get rid of the overlay data
   if (this.fadeLevel) {
     this.animateSelection_(-1);
@@ -2054,7 +2014,6 @@ Dygraph.prototype.clearSelection = function() {
   }
   this.canvas_ctx_.clearRect(0, 0, this.width_, this.height_);
   this.fadeLevel = 0;
-  this.setLegendHTML_();
   this.selPoints_ = [];
   this.lastx_ = -1;
   this.lastRow_ = -1;
@@ -2071,19 +2030,34 @@ Dygraph.prototype.getSelection = function() {
     return -1;
   }
 
-  for (var row=0; row<this.layout_.points.length; row++ ) {
-    if (this.layout_.points[row].x == this.selPoints_[0].x) {
-      return row + this.getLeftBoundary_();
+  for (var setIdx = 0; setIdx < this.layout_.points.length; setIdx++) {
+    var points = this.layout_.points[setIdx];
+    for (var row = 0; row < points.length; row++) {
+      if (points[row].x == this.selPoints_[0].x) {
+        return row + this.getLeftBoundary_();
+      }
     }
   }
   return -1;
 };
 
+/**
+ * Returns the name of the currently-highlighted series.
+ * Only available when the highlightSeriesOpts option is in use.
+ */
 Dygraph.prototype.getHighlightSeries = function() {
   return this.highlightSet_;
 };
 
 /**
+ * Returns true if the currently-highlighted series was locked
+ * via setSelection(..., seriesName, true).
+ */
+Dygraph.prototype.isSeriesLocked = function() {
+  return this.lockedSet_;
+};
+
+/**
  * Fires when there's data available to be graphed.
  * @param {String} data Raw CSV data to be plotted
  * @private
@@ -2133,7 +2107,7 @@ Dygraph.prototype.extremeValues_ = function(series) {
     // With custom bars, maxY is the max of the high values.
     for (j = 0; j < series.length; j++) {
       y = series[j][1][0];
-      if (!y) continue;
+      if (y === null || isNaN(y)) continue;
       var low = y - series[j][1][1];
       var high = y + series[j][1][2];
       if (low > y) low = y;    // this can happen with custom bars,
@@ -2176,7 +2150,10 @@ Dygraph.prototype.predraw_ = function() {
   this.computeYAxes_();
 
   // Create a new plotter.
-  if (this.plotter_) this.plotter_.clear();
+  if (this.plotter_) {
+    this.cascadeEvents_('clearChart');
+    this.plotter_.clear();
+  }
   this.plotter_ = new DygraphCanvasRenderer(this,
                                             this.hidden_,
                                             this.hidden_ctx_,
@@ -2186,10 +2163,7 @@ Dygraph.prototype.predraw_ = function() {
   // this will be until the options are available, so it's positioned here.
   this.createRollInterface_();
 
-  // Same thing applies for the labelsDiv. It's right edge should be flush with
-  // the right edge of the charting area (which may not be the same as the right
-  // edge of the div, if we have two y-axes.
-  this.positionLabelsDiv_();
+  this.cascadeEvents_('predraw');
 
   if (this.rangeSelector_) {
     this.rangeSelector_.renderStaticLayer();
@@ -2199,7 +2173,8 @@ Dygraph.prototype.predraw_ = function() {
   // rolling averages.
   this.rolledSeries_ = [null];  // x-axis is the first series and it's special
   for (var i = 1; i < this.numColumns(); i++) {
-    var logScale = this.attr_('logscale', i); // TODO(klausw): this looks wrong
+    // var logScale = this.attr_('logscale', i); // TODO(klausw): this looks wrong // konigsberg thinks so too.
+    var logScale = this.attr_('logscale');
     var series = this.extractSeries_(this.rawData_, i, logScale);
     series = this.rollingAverage(series, this.rollPeriod_);
     this.rolledSeries_.push(series);
@@ -2238,7 +2213,9 @@ Dygraph.prototype.gatherDatasets_ = function(rolledSeries, dateWindow) {
   for (i = num_series; i >= 1; i--) {
     if (!this.visibility()[i - 1]) continue;
 
-    // TODO(danvk): is this copy really necessary?
+    // Note: this copy _is_ necessary at the moment.
+    // If you remove it, it breaks zooming with error bars on.
+    // TODO(danvk): investigate further & write a test for this.
     var series = [];
     for (j = 0; j < rolledSeries[i].length; j++) {
       series.push(rolledSeries[i][j]);
@@ -2348,20 +2325,11 @@ Dygraph.prototype.gatherDatasets_ = function(rolledSeries, dateWindow) {
  * has changed. If the underlying data or options have changed, predraw_ will
  * be called before drawGraph_ is called.
  *
- * clearSelection, when undefined or true, causes this.clearSelection to be
- * called at the end of the draw operation. This should rarely be defined,
- * and never true (that is it should be undefined most of the time, and
- * rarely false.)
- *
  * @private
  */
-Dygraph.prototype.drawGraph_ = function(clearSelection) {
+Dygraph.prototype.drawGraph_ = function() {
   var start = new Date();
 
-  if (typeof(clearSelection) === 'undefined') {
-    clearSelection = true;
-  }
-
   // This is used to set the second parameter to drawCallback, below.
   var is_initial_draw = this.is_initial_draw_;
   this.is_initial_draw_ = false;
@@ -2399,7 +2367,7 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) {
   this.layout_.setDateWindow(this.dateWindow_);
   this.zoomed_x_ = tmp_zoomed_x;
   this.layout_.evaluateWithError();
-  this.renderGraph_(is_initial_draw, false);
+  this.renderGraph_(is_initial_draw);
 
   if (this.attr_("timingName")) {
     var end = new Date();
@@ -2409,32 +2377,41 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) {
   }
 };
 
-Dygraph.prototype.renderGraph_ = function(is_initial_draw, clearSelection) {
+/**
+ * This does the work of drawing the chart. It assumes that the layout and axis
+ * scales have already been set (e.g. by predraw_).
+ *
+ * @private
+ */
+Dygraph.prototype.renderGraph_ = function(is_initial_draw) {
+  this.cascadeEvents_('clearChart');
   this.plotter_.clear();
+
+  if (this.attr_('underlayCallback')) {
+    // NOTE: we pass the dygraph object to this callback twice to avoid breaking
+    // users who expect a deprecated form of this callback.
+    this.attr_('underlayCallback')(
+        this.hidden_ctx_, this.layout_.getPlotArea(), this, this);
+  }
+
+  var e = {
+    canvas: this.hidden_,
+    drawingContext: this.hidden_ctx_
+  };
+  this.cascadeEvents_('willDrawChart', e);
   this.plotter_.render();
+  this.cascadeEvents_('didDrawChart', e);
+
+  // TODO(danvk): is this a performance bottleneck when panning?
+  // The interaction canvas should already be empty in that situation.
   this.canvas_.getContext('2d').clearRect(0, 0, this.canvas_.width,
                                           this.canvas_.height);
 
   // Generate a static legend before any particular point is selected.
-  this.setLegendHTML_();
-
-  if (!is_initial_draw) {
-    if (clearSelection) {
-      if (typeof(this.selPoints_) !== 'undefined' && this.selPoints_.length) {
-        // We should select the point nearest the page x/y here, but it's easier
-        // to just clear the selection. This prevents erroneous hover dots from
-        // being displayed.
-        this.clearSelection();
-      } else {
-        this.clearSelection();
-      }
-    }
-  }
 
   if (this.rangeSelector_) {
     this.rangeSelector_.renderInteractiveLayer();
   }
-
   if (this.attr_("drawCallback") !== null) {
     this.attr_("drawCallback")(this, is_initial_draw);
   }
@@ -2446,9 +2423,8 @@ Dygraph.prototype.renderGraph_ = function(is_initial_draw, clearSelection) {
  * currently being displayed. This includes things like the number of axes and
  * the style of the axes. It does not include the range of each axis and its
  * tick marks.
- * This fills in this.axes_ and this.seriesToAxisMap_.
+ * This fills in this.axes_.
  * axes_ = [ { options } ]
- * seriesToAxisMap_ = { seriesName: 0, seriesName2: 1, ... }
  *   indices are into the axes_ array.
  */
 Dygraph.prototype.computeYAxes_ = function() {
@@ -2462,70 +2438,16 @@ Dygraph.prototype.computeYAxes_ = function() {
     }
   }
 
-  this.axes_ = [{ yAxisId : 0, g : this }];  // always have at least one y-axis.
-  this.seriesToAxisMap_ = {};
-
-  // Get a list of series names.
-  var labels = this.attr_("labels");
-  var series = {};
-  for (i = 1; i < labels.length; i++) series[labels[i]] = (i - 1);
-
-  // all options which could be applied per-axis:
-  var axisOptions = [
-    'includeZero',
-    'valueRange',
-    'labelsKMB',
-    'labelsKMG2',
-    'pixelsPerYLabel',
-    'yAxisLabelWidth',
-    'axisLabelFontSize',
-    'axisTickSize',
-    'logscale'
-  ];
-
-  // Copy global axis options over to the first axis.
-  for (i = 0; i < axisOptions.length; i++) {
-    var k = axisOptions[i];
-    v = this.attr_(k);
-    if (v) this.axes_[0][k] = v;
-  }
-
+  // this.axes_ doesn't match this.attributes_.axes_.options. It's used for
+  // data computation as well as options storage.
   // Go through once and add all the axes.
-  for (seriesName in series) {
-    if (!series.hasOwnProperty(seriesName)) continue;
-    axis = this.attr_("axis", seriesName);
-    if (axis === null) {
-      this.seriesToAxisMap_[seriesName] = 0;
-      continue;
-    }
-    if (typeof(axis) == 'object') {
-      // Add a new axis, making a copy of its per-axis options.
-      opts = {};
-      Dygraph.update(opts, this.axes_[0]);
-      Dygraph.update(opts, { valueRange: null });  // shouldn't inherit this.
-      var yAxisId = this.axes_.length;
-      opts.yAxisId = yAxisId;
-      opts.g = this;
-      Dygraph.update(opts, axis);
-      this.axes_.push(opts);
-      this.seriesToAxisMap_[seriesName] = yAxisId;
-    }
-  }
-
-  // Go through one more time and assign series to an axis defined by another
-  // series, e.g. { 'Y1: { axis: {} }, 'Y2': { axis: 'Y1' } }
-  for (seriesName in series) {
-    if (!series.hasOwnProperty(seriesName)) continue;
-    axis = this.attr_("axis", seriesName);
-    if (typeof(axis) == 'string') {
-      if (!this.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 = this.seriesToAxisMap_[axis];
-      this.seriesToAxisMap_[seriesName] = idx;
-    }
+  this.axes_ = [];
+  
+  for (axis = 0; axis < this.attributes_.numAxes(); axis++) {
+    // Add a new axis, making a copy of its per-axis options.
+    opts = { g : this };
+    Dygraph.update(opts, this.attributes_.axisOptions(axis));
+    this.axes_[axis] = opts;
   }
 
   if (valueWindows !== undefined) {
@@ -2535,7 +2457,6 @@ Dygraph.prototype.computeYAxes_ = function() {
     }
   }
 
-  // New axes options
   for (axis = 0; axis < this.axes_.length; axis++) {
     if (axis === 0) {
       opts = this.optionsViewForAxis_('y' + (axis ? '2' : ''));
@@ -2549,7 +2470,6 @@ Dygraph.prototype.computeYAxes_ = function() {
       }
     }
   }
-
 };
 
 /**
@@ -2557,13 +2477,7 @@ Dygraph.prototype.computeYAxes_ = function() {
  * @return {Number} the number of axes.
  */
 Dygraph.prototype.numAxes = function() {
-  var last_axis = 0;
-  for (var series in this.seriesToAxisMap_) {
-    if (!this.seriesToAxisMap_.hasOwnProperty(series)) continue;
-    var idx = this.seriesToAxisMap_[series];
-    if (idx > last_axis) last_axis = idx;
-  }
-  return 1 + last_axis;
+  return this.attributes_.numAxes();
 };
 
 /**
@@ -2575,7 +2489,7 @@ Dygraph.prototype.numAxes = function() {
  */
 Dygraph.prototype.axisPropertiesForSeries = function(series) {
   // TODO(danvk): handle errors.
-  return this.axes_[this.seriesToAxisMap_[series]];
+  return this.axes_[this.attributes_.axisForSeries(series)];
 };
 
 /**
@@ -2585,25 +2499,20 @@ Dygraph.prototype.axisPropertiesForSeries = function(series) {
  * This fills in the valueRange and ticks fields in each entry of this.axes_.
  */
 Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
-  // Build a map from axis number -> [list of series names]
-  var seriesForAxis = [], series;
-  for (series in this.seriesToAxisMap_) {
-    if (!this.seriesToAxisMap_.hasOwnProperty(series)) continue;
-    var idx = this.seriesToAxisMap_[series];
-    while (seriesForAxis.length <= idx) seriesForAxis.push([]);
-    seriesForAxis[idx].push(series);
-  }
+  var series;
+  var numAxes = this.attributes_.numAxes();
 
   // Compute extreme values, a span and tick marks for each axis.
-  for (var i = 0; i < this.axes_.length; i++) {
+  for (var i = 0; i < numAxes; i++) {
     var axis = this.axes_[i];
 
-    if (!seriesForAxis[i]) {
+    series = this.attributes_.seriesForAxis(i);
+
+    if (series.length == 0) {
       // If no series are defined or visible then use a reasonable default
       axis.extremeRange = [0, 1];
     } else {
       // Calculate the extremes of extremes.
-      series = seriesForAxis[i];
       var minY = Infinity;  // extremes[series[0]][0];
       var maxY = -Infinity;  // extremes[series[0]][1];
       var extremeMinY, extremeMaxY;
@@ -2705,10 +2614,12 @@ Dygraph.prototype.computeYAxisRanges_ = function(extremes) {
  *
  * This is where undesirable points (i.e. negative values on log scales and
  * missing values through which we wish to connect lines) are dropped.
- * 
+ * TODO(danvk): the "missing values" bit above doesn't seem right.
+ *
  * @private
  */
 Dygraph.prototype.extractSeries_ = function(rawData, i, logScale) {
+  // TODO(danvk): pre-allocate series here.
   var series = [];
   for (var j = 0; j < rawData.length; j++) {
     var x = rawData[j][0];
@@ -2953,7 +2864,8 @@ Dygraph.prototype.parseFloat_ = function(x, opt_line_no, opt_line) {
  */
 Dygraph.prototype.parseCSV_ = function(data) {
   var ret = [];
-  var lines = data.split("\n");
+  var line_delimiter = Dygraph.detectLineDelimiter(data);
+  var lines = data.split(line_delimiter || "\n");
   var vals, j;
 
   // Use the default delimiter or fall back to a tab if that makes sense.
@@ -2967,6 +2879,7 @@ Dygraph.prototype.parseCSV_ = function(data) {
     // User hasn't explicitly set labels, so they're (presumably) in the CSV.
     start = 1;
     this.attrs_.labels = lines[0].split(delim);  // NOTE: _not_ user_attrs_.
+    this.attributes_.reparseSeries();
   }
   var line_no = 0;
 
@@ -3103,11 +3016,19 @@ Dygraph.prototype.parseArray_ = function(data) {
               "in the options parameter");
     this.attrs_.labels = [ "X" ];
     for (i = 1; i < data[0].length; i++) {
-      this.attrs_.labels.push("Y" + i);
+      this.attrs_.labels.push("Y" + i); // Not user_attrs_.
+    }
+    this.attributes_.reparseSeries();
+  } else {
+    var num_labels = this.attr_("labels");
+    if (num_labels.length != data[0].length) {
+      this.error("Mismatch between number of labels (" + num_labels +
+          ") and number of columns in array (" + data[0].length + ")");
+      return null;
     }
   }
 
-  if (Dygraph.isDateLike(data[0][0])) {
+  if (Dygraph.isDateLike(data[0][0]) {
     // Some intelligent defaults for a date x-axis.
     this.attrs_.axes.x.valueFormatter = Dygraph.dateString_;
     this.attrs_.axes.x.axisLabelFormatter = Dygraph.dateAxisFormatter;
@@ -3160,7 +3081,7 @@ Dygraph.prototype.parseDataTable_ = function(data) {
       num = Math.floor((num - 1) / 26);
     }
     return shortText;
-  }
+  };
 
   var cols = data.getNumberOfColumns();
   var rows = data.getNumberOfRows();
@@ -3302,7 +3223,8 @@ Dygraph.prototype.start_ = function() {
     this.predraw_();
   } else if (typeof data == 'string') {
     // Heuristic: a newline means it's CSV data. Otherwise it's an URL.
-    if (data.indexOf('\n') >= 0) {
+    var line_delimiter = Dygraph.detectLineDelimiter(data);
+    if (line_delimiter) {
       this.loadedEvent_(data);
     } else {
       var req = new XMLHttpRequest();
@@ -3374,6 +3296,8 @@ Dygraph.prototype.updateOptions = function(input_attrs, block_redraw) {
 
   Dygraph.updateDeep(this.user_attrs_, attrs);
 
+  this.attributes_.reparseSeries();
+
   if (file) {
     this.file_ = file;
     if (!block_redraw) this.start_();
@@ -3382,7 +3306,7 @@ Dygraph.prototype.updateOptions = function(input_attrs, block_redraw) {
       if (requiresNewPoints) {
         this.predraw_();
       } else {
-        this.renderGraph_(false, false);
+        this.renderGraph_(false);
       }
     }
   }
@@ -3408,6 +3332,10 @@ Dygraph.mapLegacyOptions_ = function(attrs) {
   };
   var map = function(opt, axis, new_opt) {
     if (typeof(attrs[opt]) != 'undefined') {
+      Dygraph.warn("Option " + opt + " is deprecated. Use the " +
+          new_opt + " option for the " + axis + " axis instead. " +
+          "(e.g. { axes : { " + axis + " : { " + new_opt + " : ... } } } " +
+          "(see http://dygraphs.com/per-axis.html for more information.");
       set(axis, new_opt, attrs[opt]);
       delete my_attrs[opt];
     }
@@ -3553,7 +3481,7 @@ Dygraph.prototype.annotations = function() {
  * Get the list of label names for this graph. The first column is the
  * x-axis, so the data series names start at index 1.
  */
-Dygraph.prototype.getLabels = function(name) {
+Dygraph.prototype.getLabels = function() {
   return this.attr_("labels").slice();
 };
 
@@ -3581,6 +3509,7 @@ Dygraph.prototype.datasetIndexFromSetName_ = function(name) {
  * called once -- all calls after the first will return immediately.
  */
 Dygraph.addAnnotationRule = function() {
+  // TODO(danvk): move this function into plugins/annotations.js?
   if (Dygraph.addedAnnotationCSS) return;
 
   var rule = "border: 1px solid black; " +