Add support for closest-series highlighting
authorKlaus Weidner <klausw@google.com>
Sat, 25 Feb 2012 04:01:20 +0000 (20:01 -0800)
committerKlaus Weidner <klausw@google.com>
Sat, 25 Feb 2012 04:01:31 +0000 (20:01 -0800)
The new option highlightSeriesOpts applies options such as strokeWidth
to the timeseries that's currently closest to the mouse Y position,
and also passes the name of that timeseries to the highlightCallback.

There's also a new optional seriesName argument to setSelection().

See the new tests in callback.js for examples.

Also add a "highlight" class label to the currently-highlighted
timeseries in the legend view to facilitate styling via CSS.

The new options strokeBorderWidth and strokeBorderColor allow drawing a
border around graph lines to make them stand out better. This is
intended for highlightSeriesOpts, but also works when used for all
lines.

There's also support for background fading for series highlighting,
optionally animated.

New options:
  highlightSeriesOpts (default null)
  strokeBorderWidth (default 0 == off)
  strokeBorderColor (default white)
  highlightSeriesBackgroundFade (default 0 == off)
  highlightSeriesAnimated (default false)

auto_tests/tests/callback.js
dygraph-canvas.js
dygraph-options-reference.js
dygraph.js

index 1697e21..9643da5 100644 (file)
@@ -7,7 +7,10 @@
 var CallbackTestCase = TestCase("callback");
 
 CallbackTestCase.prototype.setUp = function() {
-  document.body.innerHTML = "<div id='graph'></div>";
+  document.body.innerHTML = "<div id='graph'></div><div id='selection'></div>";
+  this.styleSheet = document.createElement("style");
+  this.styleSheet.type = "text/css";
+  document.getElementsByTagName("head")[0].appendChild(this.styleSheet);
 };
 
 CallbackTestCase.prototype.tearDown = function() {
@@ -42,7 +45,7 @@ CallbackTestCase.prototype.tearDown = function() {
          width: 100,
          height : 100,
          visibility: [false, true, true],
-         highlightCallback : highlightCallback,
+         highlightCallback : highlightCallback
        });
 
    DygraphOps.dispatchMouseMove(g, 13, 10);
@@ -52,3 +55,109 @@ CallbackTestCase.prototype.tearDown = function() {
    //check there are only two points (because first series is hidden)
    assertEquals(2, h_pts.length);
  };
+
+var runClosestTest = function(isStacked, widthNormal, widthHighlighted) {
+  var h_row;
+  var h_pts;
+  var h_series;
+
+  var graph = document.getElementById("graph");
+  var g = new Dygraph(graph, data,
+      {
+        width: 600,
+        height : 400,
+        visibility: [false, true, true],
+        stackedGraph: isStacked,
+        strokeWidth: widthNormal,
+        strokeBorderWidth: 2,
+        highlightCircleSize: widthNormal * 2,
+        highlightSeriesBackgroundFade: 0.7,
+        highlightSeriesAnimate: true,
+
+        highlightSeriesOpts: {
+          strokeWidth: widthHighlighted,
+          highlightCircleSize: widthHighlighted * 2
+        }
+      });
+
+  var highlightCallback  =  function(e, x, pts, row, set) {
+    h_row = row;
+    h_pts = pts;
+    h_series = set;
+    document.getElementById('selection').innerHTML='row=' + row + ', set=' + set;
+  };
+
+  g.updateOptions({highlightCallback: highlightCallback}, true);
+
+  if (isStacked) {
+    DygraphOps.dispatchMouseMove(g, 11.45, 1.4);
+    assertEquals(1, h_row);
+    assertEquals('c', h_series);
+
+    //now move up in the same row
+    DygraphOps.dispatchMouseMove(g, 11.45, 1.5);
+    assertEquals(1, h_row);
+    assertEquals('b', h_series);
+
+    //and a bit to the right
+    DygraphOps.dispatchMouseMove(g, 11.55, 1.5);
+    assertEquals(2, h_row);
+    assertEquals('c', h_series);
+  } else {
+    DygraphOps.dispatchMouseMove(g, 11, 1.5);
+    assertEquals(1, h_row);
+    assertEquals('c', h_series);
+
+    //now move up in the same row
+    DygraphOps.dispatchMouseMove(g, 11, 2.5);
+    assertEquals(1, h_row);
+    assertEquals('b', h_series);
+  }
+
+  return g;
+};
+
+/**
+ * Test basic closest-point highlighting.
+ */
+CallbackTestCase.prototype.testClosestPointCallback = function() {
+  runClosestTest(false, 1, 3);
+}
+
+/**
+ * Test setSelection() with series name
+ */
+CallbackTestCase.prototype.testSetSelection = function() {
+  var g = runClosestTest(false, 1, 3);
+  assertEquals(1, g.attr_('strokeWidth', 'c'));
+  g.setSelection(false, 'c');
+  assertEquals(3, g.attr_('strokeWidth', 'c'));
+}
+
+/**
+ * Test closest-point highlighting for stacked graph
+ */
+CallbackTestCase.prototype.testClosestPointStackedCallback = function() {
+  runClosestTest(true, 1, 3);
+}
+
+/**
+ * Closest-point highlighting with legend CSS - border around active series.
+ */
+CallbackTestCase.prototype.testClosestPointCallbackCss1 = function() {
+  var css = "div.dygraph-legend > span { display: block; }\n" +
+    "div.dygraph-legend > span.highlight { border: 1px solid grey; }\n";
+  this.styleSheet.innerHTML = css;
+  runClosestTest(false, 2, 4);
+}
+
+/**
+ * Closest-point highlighting with legend CSS - show only closest series.
+ */
+CallbackTestCase.prototype.testClosestPointCallbackCss2 = function() {
+  var css = "div.dygraph-legend > span { display: none; }\n" +
+    "div.dygraph-legend > span.highlight { display: inline; }\n";
+  this.styleSheet.innerHTML = css;
+  runClosestTest(false, 10, 15);
+  // TODO(klausw): verify that the highlighted line is drawn on top?
+}
index 9c2e4da..7ae3c87 100644 (file)
@@ -658,6 +658,104 @@ DygraphCanvasRenderer.prototype._renderAnnotations = function() {
   }
 };
 
+DygraphCanvasRenderer.prototype._drawStyledLine = function(
+    ctx, i, color, strokeWidth, strokePattern, drawPoints, pointSize) {
+  var isNullOrNaN = function(x) {
+    return (x === null || isNaN(x));
+  };
+
+  var stepPlot = this.attr_("stepPlot");
+  var firstIndexInSet = this.layout.setPointsOffsets[i];
+  var setLength = this.layout.setPointsLengths[i];
+  var afterLastIndexInSet = firstIndexInSet + setLength;
+  var points = this.layout.points;
+  var prevX = null;
+  var prevY = null;
+  if (!Dygraph.isArrayLike(strokePattern)) {
+    strokePattern = null;
+  }
+
+  var point;
+  ctx.save();
+  for (var j = firstIndexInSet; j < afterLastIndexInSet; j++) {
+    point = points[j];
+    if (isNullOrNaN(point.canvasy)) {
+      if (stepPlot && prevX !== null) {
+        // Draw a horizontal line to the start of the missing data
+        ctx.beginPath();
+        ctx.strokeStyle = color;
+        ctx.lineWidth = this.attr_('strokeWidth');
+        this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern);
+        ctx.stroke();
+      }
+      // this will make us move to the next point, not draw a line to it.
+      prevX = prevY = null;
+    } else {
+      // A point is "isolated" if it is non-null but both the previous
+      // and next points are null.
+      var isIsolated = (!prevX && (j == points.length - 1 ||
+                                   isNullOrNaN(points[j+1].canvasy)));
+      if (prevX === null) {
+        prevX = point.canvasx;
+        prevY = point.canvasy;
+      } else {
+        // Skip over points that will be drawn in the same pixel.
+        if (Math.round(prevX) == Math.round(point.canvasx) &&
+            Math.round(prevY) == Math.round(point.canvasy)) {
+          continue;
+        }
+        // TODO(antrob): skip over points that lie on a line that is already
+        // going to be drawn. There is no need to have more than 2
+        // consecutive points that are collinear.
+        if (strokeWidth) {
+          ctx.beginPath();
+          ctx.strokeStyle = color;
+          ctx.lineWidth = strokeWidth;
+          if (stepPlot) {
+            this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern);
+            prevX = point.canvasx;
+          }
+          this._dashedLine(ctx, prevX, prevY, point.canvasx, point.canvasy, strokePattern);
+          prevX = point.canvasx;
+          prevY = point.canvasy;
+          ctx.stroke();
+        }
+      }
+
+      if (drawPoints || isIsolated) {
+        ctx.beginPath();
+        ctx.fillStyle = color;
+        ctx.arc(point.canvasx, point.canvasy, pointSize,
+                0, 2 * Math.PI, false);
+        ctx.fill();
+      }
+    }
+  }
+  ctx.restore();
+};
+
+DygraphCanvasRenderer.prototype._drawLine = function(ctx, i) {
+  var setNames = this.layout.setNames;
+  var setName = setNames[i];
+
+  var strokeWidth = this.dygraph_.attr_("strokeWidth", setName);
+  var borderWidth = this.dygraph_.attr_("strokeBorderWidth", setName);
+  if (borderWidth && strokeWidth) {
+    this._drawStyledLine(ctx, i,
+        this.dygraph_.attr_("strokeBorderColor", setName),
+        strokeWidth + 2 * borderWidth,
+        this.dygraph_.attr_("strokePattern", setName),
+        this.dygraph_.attr_("drawPoints", setName),
+        this.dygraph_.attr_("pointSize", setName));
+  }
+
+  this._drawStyledLine(ctx, i,
+      this.colors[setName],
+      strokeWidth,
+      this.dygraph_.attr_("strokePattern", setName),
+      this.dygraph_.attr_("drawPoints", setName),
+      this.dygraph_.attr_("pointSize", setName));
+};
 
 /**
  * Actually draw the lines chart, including error bars.
@@ -665,12 +763,8 @@ DygraphCanvasRenderer.prototype._renderAnnotations = function() {
  * @private
  */
 DygraphCanvasRenderer.prototype._renderLineChart = function() {
-  var isNullOrNaN = function(x) {
-    return (x === null || isNaN(x));
-  };
-
   // TODO(danvk): use this.attr_ for many of these.
-  var context = this.elementContext;
+  var ctx = this.elementContext;
   var fillAlpha = this.attr_('fillAlpha');
   var errorBars = this.attr_("errorBars") || this.attr_("customBars");
   var fillGraph = this.attr_("fillGraph");
@@ -698,8 +792,8 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
   }
 
   // create paths
-  var ctx = context;
   if (errorBars) {
+    ctx.save();
     if (fillGraph) {
       this.dygraph_.warn("Can't use fillGraph option with error bars");
     }
@@ -710,7 +804,6 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       color = this.colors[setName];
 
       // setup graphics context
-      ctx.save();
       prevX = NaN;
       prevY = NaN;
       prevYs = [-1, -1];
@@ -759,7 +852,9 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       }
       ctx.fill();
     }
+    ctx.restore();
   } else if (fillGraph) {
+    ctx.save();
     var baseline = [];  // for stacked graphs: baseline for filling
 
     // process sets in reverse order (needed for stacked graphs)
@@ -773,7 +868,6 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       axisY = this.area.h * axisY + this.area.y;
 
       // setup graphics context
-      ctx.save();
       prevX = NaN;
       prevYs = [-1, -1];
       yscale = axis.yscale;
@@ -815,6 +909,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       }
       ctx.fill();
     }
+    ctx.restore();
   }
 
   // Drawing the lines.
@@ -822,80 +917,8 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
   var afterLastIndexInSet = 0;
   var setLength = 0;
   for (i = 0; i < setCount; i += 1) {
-    firstIndexInSet = this.layout.setPointsOffsets[i];
-    setLength = this.layout.setPointsLengths[i];
-    afterLastIndexInSet = firstIndexInSet + setLength;
-    setName = setNames[i];
-    color = this.colors[setName];
-    var strokeWidth = this.dygraph_.attr_("strokeWidth", setName);
-
-    // setup graphics context
-    context.save();
-    var pointSize = this.dygraph_.attr_("pointSize", setName);
-    prevX = null;
-    prevY = null;
-    var drawPoints = this.dygraph_.attr_("drawPoints", setName);
-    var strokePattern = this.dygraph_.attr_("strokePattern", setName);
-    if (!Dygraph.isArrayLike(strokePattern)) {
-      strokePattern = null;
-    }
-    for (j = firstIndexInSet; j < afterLastIndexInSet; j++) {
-      point = points[j];
-      if (isNullOrNaN(point.canvasy)) {
-        if (stepPlot && prevX !== null) {
-          // Draw a horizontal line to the start of the missing data
-          ctx.beginPath();
-          ctx.strokeStyle = color;
-          ctx.lineWidth = this.attr_('strokeWidth');
-          this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern);
-          ctx.stroke();
-        }
-        // this will make us move to the next point, not draw a line to it.
-        prevX = prevY = null;
-      } else {
-        // A point is "isolated" if it is non-null but both the previous
-        // and next points are null.
-        var isIsolated = (!prevX && (j == points.length - 1 ||
-                                     isNullOrNaN(points[j+1].canvasy)));
-        if (prevX === null) {
-          prevX = point.canvasx;
-          prevY = point.canvasy;
-        } else {
-          // Skip over points that will be drawn in the same pixel.
-          if (Math.round(prevX) == Math.round(point.canvasx) &&
-              Math.round(prevY) == Math.round(point.canvasy)) {
-            continue;
-          }
-          // TODO(antrob): skip over points that lie on a line that is already
-          // going to be drawn. There is no need to have more than 2
-          // consecutive points that are collinear.
-          if (strokeWidth) {
-            ctx.beginPath();
-            ctx.strokeStyle = color;
-            ctx.lineWidth = strokeWidth;
-            if (stepPlot) {
-              this._dashedLine(ctx, prevX, prevY, point.canvasx, prevY, strokePattern);
-              prevX = point.canvasx;
-            }
-            this._dashedLine(ctx, prevX, prevY, point.canvasx, point.canvasy, strokePattern);
-            prevX = point.canvasx;
-            prevY = point.canvasy;
-            ctx.stroke();
-          }
-        }
-
-        if (drawPoints || isIsolated) {
-          ctx.beginPath();
-          ctx.fillStyle = color;
-          ctx.arc(point.canvasx, point.canvasy, pointSize,
-                  0, 2 * Math.PI, false);
-          ctx.fill();
-        }
-      }
-    }
+    this._drawLine(ctx, i);
   }
-
-  context.restore();
 };
 
 /**
index a94a54f..62dc2c3 100644 (file)
@@ -78,8 +78,26 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
   "highlightCallback": {
     "default": "null",
     "labels": ["Callbacks"],
-    "type": "function(event, x, points,row)",
-    "description": "When set, this callback gets called every time a new point is highlighted. The parameters are the JavaScript mousemove event, the x-coordinate of the highlighted points and an array of highlighted points: <code>[ {name: 'series', yval: y-value}, &hellip; ]</code>"
+    "type": "function(event, x, points, row, closestSeries)",
+    "description": "When set, this callback gets called every time a new point is highlighted. The parameters are the JavaScript mousemove event, the x-coordinate of the highlighted points, an array of highlighted points: <code>[ {name: 'series', yval: y-value}, &hellip; ]</code>, and the index of the data row corresponding to the x-coordinate. If highlightSeriesOpts is set, closestSeries is passed as an additional argument giving the name of the timeseries closest to the mouse pointer, and the callback gets called whenever this changes, including vertical movement."
+  },
+  "highlightSeriesOpts": {
+    "default": "null",
+    "labels": ["Interactive Elements"],
+    "type": "Object",
+    "description": "When set, the options from this object are applied to the timeseries closest to the mouse pointer for interactive highlighting. See also 'highlightCallback'. Example: highlightSeriesOpts: { strokeWidth: 3 }."
+  },
+  "highlightSeriesBackgroundFade": {
+    "default": "0",
+    "labels": ["Interactive Elements"],
+    "type": "number",
+    "description": "When nonzero, dim the background while highlighting series. 0=fully visible, 1=hidden"
+  },
+  "highlightSeriesAnimated": {
+    "default": "false",
+    "labels": ["Interactive Elements"],
+    "type": "Object",
+    "description": "Animate the background dimming for nonzero highlightSeriesBackgroundFade."
   },
   "includeZero": {
     "default": "false",
@@ -270,6 +288,20 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
     "example": "[10, 2, 5, 2]",
     "description": "A custom pattern array where the even index is a draw and odd is a space in pixels. If null then it draws a solid line. The array should have a even length as any odd lengthed array could be expressed as a smaller even length array."
   },
+  "strokeBorderWidth": {
+    "default": "null",
+    "labels": ["Data Line display"],
+    "type": "integer",
+    "example": "0.5, 2.0",
+    "description": "Draw a border around graph lines to make crossing lines more easily distinguishable. Useful for graphs with many lines."
+  },
+  "strokeBorderColor": {
+    "default": "white",
+    "labels": ["Data Line display"],
+    "type": "string",
+    "example": "red, #ccffdd",
+    "description": "Color for the line border used if strokeBorderWidth is set."
+  },
   "wilsonInterval": {
     "default": "true",
     "labels": ["Error Bars"],
index 0748d26..735be8c 100644 (file)
@@ -186,6 +186,9 @@ Dygraph.dateAxisFormatter = function(date, granularity) {
 // Default attribute values.
 Dygraph.DEFAULT_ATTRS = {
   highlightCircleSize: 3,
+  highlightSeriesOpts: null,
+  highlightSeriesBackgroundFade: 0,
+  highlightSeriesAnimated: false,
 
   labelsDivWidth: 250,
   labelsDivStyles: {
@@ -202,6 +205,8 @@ Dygraph.DEFAULT_ATTRS = {
   sigFigs: null,
 
   strokeWidth: 1.0,
+  strokeBorderWidth: 0,
+  strokeBorderColor: "white",
 
   axisTickSize: 3,
   axisLabelFontSize: 14,
@@ -398,6 +403,7 @@ Dygraph.prototype.__init__ = function(div, file, attrs) {
 
   this.boundaryIds_ = [];
   this.setIndexByName_ = {};
+  this.datasetIndex_ = [];
 
   // Create the containing DIV and other interactive elements
   this.createInterface_();
@@ -452,18 +458,29 @@ Dygraph.prototype.attr_ = function(name, seriesName) {
     Dygraph.OPTIONS_REFERENCE[name] = true;
   }
 // </REMOVE_FOR_COMBINED>
-  if (this.user_attrs_ !== null && seriesName &&
-      typeof(this.user_attrs_[seriesName]) != 'undefined' &&
-      this.user_attrs_[seriesName] !== null &&
-      typeof(this.user_attrs_[seriesName][name]) != 'undefined') {
-    return this.user_attrs_[seriesName][name];
-  } else if (this.user_attrs_ !== null && typeof(this.user_attrs_[name]) != 'undefined') {
-    return this.user_attrs_[name];
-  } else if (this.attrs_ !== null && typeof(this.attrs_[name]) != 'undefined') {
-    return this.attrs_[name];
-  } else {
-    return null;
+
+  var sources = [];
+  sources.push(this.attrs_);
+  if (this.user_attrs_) sources.push(this.user_attrs_);
+  if (this.user_attrs_ && 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;
 };
 
 /**
@@ -1461,6 +1478,131 @@ Dygraph.prototype.doAnimatedZoom = function(oldXRange, newXRange, oldYRanges, ne
 };
 
 /**
+ * Get the current graph's area object.
+ *
+ * Returns: {x, y, w, h}
+ */
+Dygraph.prototype.getArea = function() {
+  return this.plotter_.area;
+};
+
+/**
+ * Convert a mouse event to DOM coordinates relative to the graph origin.
+ *
+ * Returns a two-element array: [X, Y].
+ */
+Dygraph.prototype.eventToDomCoords = function(event) {
+  var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_);
+  var canvasy = Dygraph.pageY(event) - Dygraph.findPosY(this.mouseEventElement_);
+  return [canvasx, canvasy];
+};
+
+/**
+ * Given a canvas X coordinate, find the closest row.
+ * @param {Number} domX graph-relative DOM X coordinate
+ * Returns: row number, integer
+ * @private
+ */
+Dygraph.prototype.findClosestRow = function(domX) {
+  var minDistX = null;
+  var idx = -1;
+  var points = this.layout_.points;
+  var l = points.length;
+  for (var i = 0; i < l; i++) {
+    var point = points[i];
+    if (point === null) continue;
+    var dist = Math.abs(point.canvasx - domX);
+    if (minDistX !== null && dist >= minDistX) continue;
+    minDistX = dist;
+    idx = i;
+  }
+  return this.idxToRow_(idx);
+};
+
+/**
+ * Given canvas X,Y coordinates, find the closest point
+ * @param {Number} domX graph-relative DOM X coordinate
+ * @param {Number} domY graph-relative DOM Y coordinate
+ * Returns: {row, seriesName, point}
+ * @private
+ */
+Dygraph.prototype.findClosestPoint = function(domX, domY) {
+  var minDist = null;
+  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];
+      if (point === null) continue;
+      dx = point.canvasx - domX;
+      dy = point.canvasy - domY;
+      dist = dx * dx + dy * dy;
+      if (minDist !== null && dist >= minDist) continue;
+      minDist = dist;
+      closestPoint = point;
+      closestSeries = setIdx;
+      idx = i;
+    }
+  }
+  var name = this.layout_.setNames[closestSeries];
+  return {
+    row: idx,
+    seriesName: name,
+    point: closestPoint
+  };
+};
+
+/**
+ * Given canvas X,Y coordinates, find the touched area in a stacked graph.
+ * @param {Number} domX graph-relative DOM X coordinate
+ * @param {Number} domY graph-relative DOM Y coordinate
+ * Returns: {row, seriesName, point}
+ * @private
+ */
+Dygraph.prototype.findStackedPoint = function(domX, domY) {
+  var row = this.findClosestRow(domX);
+  var points = 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 (row >= len) continue;
+    var p1 = points[first + row];
+    var py = p1.canvasy;
+    if (domX > p1.canvasx && row + 1 < len) {
+      // interpolate series Y value using next point
+      var p2 = points[first + row + 1];
+      var dx = p2.canvasx - p1.canvasx;
+      if (dx > 0) {
+        var r = (domX - p1.canvasx) / dx;
+        py += r * (p2.canvasy - p1.canvasy);
+      }
+    } else if (domX < p1.canvasx && row > 0) {
+      // interpolate series Y value using previous point
+      var p0 = points[first + row - 1];
+      var dx = p1.canvasx - p0.canvasx;
+      if (dx > 0) {
+        var r = (p1.canvasx - domX) / dx;
+        py += r * (p0.canvasy - p1.canvasy);
+      }
+    }
+    // Stop if the point (domX, py) is above this series' upper edge
+    if (setIdx > 0 && py >= domY) break;
+    closestPoint = p1;
+    closestSeries = setIdx;
+  }
+  var name = this.layout_.setNames[closestSeries];
+  return {
+    row: row,
+    seriesName: name,
+    point: closestPoint
+  };
+};
+
+/**
  * When the mouse moves in the canvas, display information about a nearby data
  * point and draw dots over those points in the data series. This function
  * takes care of cleanup of previously-drawn dots.
@@ -1472,63 +1614,36 @@ Dygraph.prototype.mouseMove_ = function(event) {
   var points = this.layout_.points;
   if (points === undefined) return;
 
-  var canvasx = Dygraph.pageX(event) - Dygraph.findPosX(this.mouseEventElement_);
-
-  var lastx = -1;
-  var i;
+  var canvasCoords = this.eventToDomCoords(event);
+  var canvasx = canvasCoords[0];
+  var canvasy = canvasCoords[1];
 
-  // Loop through all the points and find the date nearest to our current
-  // location.
-  var minDist = 1e+100;
-  var idx = -1;
-  for (i = 0; i < points.length; i++) {
-    var point = points[i];
-    if (point === null) continue;
-    var dist = Math.abs(point.canvasx - canvasx);
-    if (dist > minDist) continue;
-    minDist = dist;
-    idx = i;
+  var mouseoverCallback = this.attr_("mouseoverCallback");
+  if (mouseoverCallback) {
+    var highlightRow = this.idxToRow_(idx);
+    var ret = mouseoverCallback(this, event);
+    if (ret) return;
   }
-  if (idx >= 0) lastx = points[idx].xval;
 
-  // Extract the points we've selected
-  this.selPoints_ = [];
-  var l = points.length;
-  if (!this.attr_("stackedGraph")) {
-    for (i = 0; i < l; i++) {
-      if (points[i].xval == lastx) {
-        this.selPoints_.push(points[i]);
-      }
+  var highlightSeriesOpts = this.attr_("highlightSeriesOpts");
+  var selectionChanged = false;
+  if (highlightSeriesOpts) {
+    var closest;
+    if (this.attr_("stackedGraph")) {
+      closest = this.findStackedPoint(canvasx, canvasy);
+    } else {
+      closest = this.findClosestPoint(canvasx, canvasy);
     }
+    selectionChanged = this.setSelection(closest.row, closest.seriesName);
   } else {
-    // Need to 'unstack' points starting from the bottom
-    var cumulative_sum = 0;
-    for (i = l - 1; i >= 0; i--) {
-      if (points[i].xval == lastx) {
-        var p = {};  // Clone the point since we modify it
-        for (var k in points[i]) {
-          p[k] = points[i][k];
-        }
-        p.yval -= cumulative_sum;
-        cumulative_sum += p.yval;
-        this.selPoints_.push(p);
-      }
-    }
-    this.selPoints_.reverse();
+    var idx = this.findClosestRow(canvasx);
+    selectionChanged = this.setSelection(idx);
   }
 
-  if (this.attr_("highlightCallback")) {
-    var px = this.lastx_;
-    if (px !== null && lastx != px) {
-      // only fire if the selected point has changed.
-      this.attr_("highlightCallback")(event, lastx, this.selPoints_, this.idxToRow_(idx));
-    }
+  var callback = this.attr_("highlightCallback");
+  if (callback && selectionChanged) {
+    callback(event, this.lastx_, this.selPoints_, this.lastRow_, this.highlightSet_);
   }
-
-  // Save last x position for callbacks.
-  this.lastx_ = lastx;
-
-  this.updateSelection_();
 };
 
 /**
@@ -1659,7 +1774,7 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) {
       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 + 
+      html += "<span style='font-weight: bold; color: " + c + ";'>" + dash +
         " " + labels[i] + "</span>";
     }
     return html;
@@ -1687,9 +1802,10 @@ Dygraph.prototype.generateLegendHTML_ = function(x, sel_points, oneEmWidth) {
     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 += " <b><span style='color: " + c + ";'>" + pt.name +
-        "</span></b>:" + yval;
+    html += "<span" + cls + ">" + " <b><span style='color: " + c + ";'>" + pt.name +
+        "</span></b>:" + yval + "</span>";
   }
   return html;
 };
@@ -1721,16 +1837,67 @@ Dygraph.prototype.setLegendHTML_ = function(x, sel_points) {
   }
 };
 
+Dygraph.prototype.animateSelection_ = function(direction) {
+  var totalSteps = 10;
+  var millis = 30;
+  if (this.fadeLevel === undefined) {
+    this.fadeLevel = 0;
+    this.animateId = 0;
+  }
+  var start = this.fadeLevel;
+  var steps = direction < 0 ? start : totalSteps - start;
+  if (steps <= 0) {
+    if (this.fadeLevel) {
+      this.updateSelection_(1.0);
+    }
+    return;
+  }
+
+  var thisId = ++this.animateId;
+  var that = this;
+  Dygraph.repeatAndCleanup(function(n) {
+        // ignore simultaneous animations
+        if (that.animateId != thisId) return;
+
+        that.fadeLevel += direction;
+        if (that.fadeLevel === 0) {
+          that.clearSelection();
+        } else {
+          that.updateSelection_(that.fadeLevel / totalSteps);
+        }
+      },
+      steps, millis, function() {});
+};
+
 /**
  * Draw dots over the selectied points in the data series. This function
  * takes care of cleanup of previously-drawn dots.
  * @private
  */
-Dygraph.prototype.updateSelection_ = function() {
+Dygraph.prototype.updateSelection_ = function(opt_animFraction) {
   // Clear the previously drawn vertical, if there is one
   var i;
   var ctx = this.canvas_ctx_;
-  if (this.previousVerticalX_ >= 0) {
+  if (this.attr_('highlightSeriesOpts')) {
+    ctx.clearRect(0, 0, this.width_, this.height_);
+    var alpha = this.attr_('highlightSeriesBackgroundFade');
+    if (alpha) {
+      if (this.attr_('highlightSeriesAnimate')) {
+        if (opt_animFraction === undefined) {
+          // start a new animation
+          this.animateSelection_(1);
+          return;
+        }
+        alpha *= opt_animFraction;
+      }
+      ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')';
+      ctx.fillRect(0, 0, this.width_, this.height_);
+    }
+    var setIdx = this.datasetIndexFromSetName_(this.highlightSet_);
+    var underlay = this.attr_('highlightUnderlay');
+    if (underlay) underlay(this, ctx, setIdx);
+    this.plotter_._drawLine(ctx, setIdx);
+  } else if (this.previousVerticalX_ >= 0) {
     // Determine the maximum highlight circle size.
     var maxCircleSize = 0;
     var labels = this.attr_('labels');
@@ -1778,17 +1945,27 @@ Dygraph.prototype.updateSelection_ = function() {
  * using getSelection().
  * @param { Integer } row number that should be highlighted (i.e. appear with
  * 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.
  */
-Dygraph.prototype.setSelection = function(row) {
+Dygraph.prototype.setSelection = function(row, opt_seriesName) {
   // Extract the points we've selected
   this.selPoints_ = [];
   var pos = 0;
 
   if (row !== false) {
-    row = row - this.boundaryIds_[0][0];
+    for (var i = 0; i < this.boundaryIds_.length; i++) {
+      if (this.boundaryIds_[i] !== undefined) {
+        row -= this.boundaryIds_[i][0];
+        break;
+      }
+    }
   }
 
+  var changed = false;
   if (row !== false && row >= 0) {
+    if (row != this.lastRow_) changed = true;
+    this.lastRow_ = row;
     for (var setIdx = 0; setIdx < this.layout_.datasets.length; ++setIdx) {
       var set = this.layout_.datasets[setIdx];
       if (row < set.length) {
@@ -1802,15 +1979,26 @@ Dygraph.prototype.setSelection = function(row) {
       }
       pos += set.length;
     }
+  } else {
+    if (this.lastRow_ >= 0) changed = true;
+    this.lastRow_ = -1;
   }
 
   if (this.selPoints_.length) {
     this.lastx_ = this.selPoints_[0].xval;
-    this.updateSelection_();
   } else {
-    this.clearSelection();
+    this.lastx_ = -1;
   }
 
+  if (opt_seriesName !== undefined) {
+    if (this.highlightSet_ !== opt_seriesName) changed = true;
+    this.highlightSet_ = opt_seriesName;
+  }
+
+  if (changed) {
+    this.updateSelection_(undefined);
+  }
+  return changed;
 };
 
 /**
@@ -1834,10 +2022,17 @@ Dygraph.prototype.mouseOut_ = function(event) {
  */
 Dygraph.prototype.clearSelection = function() {
   // Get rid of the overlay data
+  if (this.fadeLevel) {
+    this.animateSelection_(-1);
+    return;
+  }
   this.canvas_ctx_.clearRect(0, 0, this.width_, this.height_);
+  this.fadeLevel = 0;
   this.setLegendHTML_();
   this.selPoints_ = [];
   this.lastx_ = -1;
+  this.lastRow_ = -1;
+  this.highlightSet_ = null;
 };
 
 /**
@@ -1858,6 +2053,10 @@ Dygraph.prototype.getSelection = function() {
   return -1;
 };
 
+Dygraph.prototype.getHighlightSeries = function() {
+  return this.highlightSet_;
+};
+
 /**
  * Fires when there's data available to be graphed.
  * @param {String} data Raw CSV data to be plotted
@@ -2131,10 +2330,12 @@ Dygraph.prototype.drawGraph_ = function(clearSelection) {
   if (labels.length > 0) {
     this.setIndexByName_[labels[0]] = 0;
   }
+  var dataIdx = 0;
   for (var i = 1; i < datasets.length; i++) {
     this.setIndexByName_[labels[i]] = i;
     if (!this.visibility()[i - 1]) continue;
     this.layout_.addDataset(labels[i], datasets[i]);
+    this.datasetIndex_[i] = dataIdx++;
   }
 
   this.computeYAxisRanges_(extremes);
@@ -3320,6 +3521,15 @@ Dygraph.prototype.indexFromSetName = function(name) {
 };
 
 /**
+ * Get the internal dataset index given its name. These are numbered starting from 0,
+ * and only count visible sets.
+ * @private
+ */
+Dygraph.prototype.datasetIndexFromSetName_ = function(name) {
+  return this.datasetIndex_[this.indexFromSetName(name)];
+};
+
+/**
  * @private
  * Adds a default style for the annotation CSS classes to the document. This is
  * only executed when annotations are actually used. It is designed to only be