Merge pull request #126 from klausw-g/highlight3
authorDan Vanderkam <dan@dygraphs.com>
Mon, 27 Feb 2012 21:47:37 +0000 (13:47 -0800)
committerDan Vanderkam <dan@dygraphs.com>
Mon, 27 Feb 2012 21:47:37 +0000 (13:47 -0800)
Add support for closest-series highlighting

auto_tests/tests/callback.js
dygraph-canvas.js
dygraph-options-reference.js
dygraph.js
tests/series-highlight.html [new file with mode: 0644]

index 3431070..fe7f7fd 100644 (file)
@@ -1,4 +1,4 @@
-/** 
+/**
  * @fileoverview Test cases for the callbacks.
  *
  * @author uemit.seren@gmail.com (Ümit Seren)
@@ -7,40 +7,43 @@
 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() {
 };
+
 var data = "X,a\,b,c\n" +
   "10,-1,1,2\n" +
   "11,0,3,1\n" +
   "12,1,4,2\n" +
   "13,0,2,3\n";
+ "10,-1,1,2\n" +
+ "11,0,3,1\n" +
+ "12,1,4,2\n" +
+ "13,0,2,3\n";
+
+
 /**
  * This tests that when the function idxToRow_ returns the proper row and the onHiglightCallback
- * is properly called when the first series is hidden (setVisibility = false) 
- * 
+ * is properly called when the  first series is hidden (setVisibility = false)
+ *
  */
 CallbackTestCase.prototype.testHighlightCallbackIsCalled = function() {
   var h_row;
   var h_pts;
 
-  var highlightCallback = function(e, x, pts, row) {
+  var highlightCallback  =  function(e, x, pts, row) {
     h_row = row;
     h_pts = pts;
-  }; 
+  };
 
   var graph = document.getElementById("graph");
   var g = new Dygraph(graph, data,
       {
         width: 100,
-        height : 100,
+        height: 100,
         visibility: [false, true, true],
-        highlightCallback : highlightCallback,
+        highlightCallback: highlightCallback
       });
 
   DygraphOps.dispatchMouseMove(g, 13, 10);
@@ -60,7 +63,7 @@ CallbackTestCase.prototype.testDrawPointCallback_disabled = function() {
 
   var callback = function() {
     called = true;
-  }; 
+  };
 
   var graph = document.getElementById("graph");
   var g = new Dygraph(graph, data, {
@@ -78,7 +81,7 @@ CallbackTestCase.prototype.testDrawPointCallback_enabled = function() {
 
   var callback = function() {
     called = true;
-  }; 
+  };
 
   var graph = document.getElementById("graph");
   var g = new Dygraph(graph, data, {
@@ -99,7 +102,7 @@ CallbackTestCase.prototype.testDrawPointCallback_pointSize = function() {
   var callback = function(g, seriesName, canvasContext, cx, cy, color, pointSizeParam) {
     pointSize = pointSizeParam;
     count++;
-  }; 
+  };
 
   var graph = document.getElementById("graph");
   var g = new Dygraph(graph, data, {
@@ -121,15 +124,15 @@ CallbackTestCase.prototype.testDrawPointCallback_pointSize = function() {
 
 /**
  * This tests that when the function idxToRow_ returns the proper row and the onHiglightCallback
- * is properly called when the first series is hidden (setVisibility = false) 
- * 
+ * is properly called when the first series is hidden (setVisibility = false)
+ *
  */
 CallbackTestCase.prototype.testDrawHighlightPointCallbackIsCalled = function() {
   var called = false;
 
   var drawHighlightPointCallback  = function() {
     called = true;
-  }; 
+  };
 
   var graph = document.getElementById("graph");
   var g = new Dygraph(graph, data,
@@ -143,3 +146,113 @@ CallbackTestCase.prototype.testDrawHighlightPointCallbackIsCalled = function() {
   DygraphOps.dispatchMouseMove(g, 13, 10);
   assertTrue(called);
 };
+
+/**
+ * Test the closest-series highlighting methods for normal and stacked modes.
+ * Also pass in line widths for plain and highlighted lines for easier visual
+ * confirmation that the highlighted line is drawn on top of the others.
+ */
+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,
+        highlightSeriesBackgroundAlpha: 0.3,
+
+        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 10936de..5fd02e5 100644 (file)
@@ -658,6 +658,114 @@ DygraphCanvasRenderer.prototype._renderAnnotations = function() {
   }
 };
 
+DygraphCanvasRenderer.prototype._drawStyledLine = function(
+    ctx, i, setName, color, strokeWidth, strokePattern, drawPoints,
+    drawPointCallback, 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;
+  var pointsOnLine = []; // Array of [canvasx, canvasy] pairs.
+  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) {
+        pointsOnLine.push([point.canvasx, point.canvasy]);
+      }
+    }
+  }
+  for (var idx = 0; idx < pointsOnLine.length; idx++) {
+    var cb = pointsOnLine[idx];
+    ctx.save();
+    drawPointCallback(
+        this.dygraph_, setName, ctx, cb[0], cb[1], color, pointSize);
+    ctx.restore();
+  }
+  firstIndexInSet = afterLastIndexInSet;
+  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);
+  var drawPointCallback = this.dygraph_.attr_("drawPointCallback", setName) ||
+      Dygraph.Circles.DEFAULT;
+  if (borderWidth && strokeWidth) {
+    this._drawStyledLine(ctx, i, setName,
+        this.dygraph_.attr_("strokeBorderColor", setName),
+        strokeWidth + 2 * borderWidth,
+        this.dygraph_.attr_("strokePattern", setName),
+        this.dygraph_.attr_("drawPoints", setName),
+        drawPointCallback,
+        this.dygraph_.attr_("pointSize", setName));
+  }
+
+  this._drawStyledLine(ctx, i, setName,
+      this.colors[setName],
+      strokeWidth,
+      this.dygraph_.attr_("strokePattern", setName),
+      this.dygraph_.attr_("drawPoints", setName),
+      drawPointCallback,
+      this.dygraph_.attr_("pointSize", setName));
+};
 
 /**
  * Actually draw the lines chart, including error bars.
@@ -665,12 +773,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 +802,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 +814,6 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       color = this.colors[setName];
 
       // setup graphics context
-      ctx.save();
       prevX = NaN;
       prevY = NaN;
       prevYs = [-1, -1];
@@ -759,7 +862,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 +878,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 +919,7 @@ DygraphCanvasRenderer.prototype._renderLineChart = function() {
       }
       ctx.fill();
     }
+    ctx.restore();
   }
 
   // Drawing the lines.
@@ -822,91 +927,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
-    // TODO(konigsberg): This function has ctx and context. Clarify the difference.
-    context.save();
-    var pointSize = this.dygraph_.attr_("pointSize", setName);
-    prevX = null;
-    prevY = null;
-    var drawPoints = this.dygraph_.attr_("drawPoints", setName);
-    var drawPointCallback = this.dygraph_.attr_("drawPointCallback", setName);
-    if (!drawPointCallback) {
-      drawPointCallback = Dygraph.Circles.DEFAULT;
-    }
-    var pointsOnLine = []; // Array of [canvasx, canvasy] pairs.
-    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) {
-          pointsOnLine.push([point.canvasx, point.canvasy]);
-        }
-      }
-    }
-    for (var idx = 0; idx < pointsOnLine.length; idx++) {
-      var cb = pointsOnLine[idx];
-      ctx.save();
-      drawPointCallback(
-          this.dygraph_, setName, ctx, cb[0], cb[1], color, pointSize);
-      ctx.restore();
-
-    }
-    firstIndexInSet = afterLastIndexInSet;
+    this._drawLine(ctx, i);
   }
-
-  context.restore();
 };
 
 /**
index 3bef499..cb8b767 100644 (file)
@@ -111,13 +111,14 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
   "highlightCallback": {
     "default": "null",
     "labels": ["Callbacks"],
-    "type": "function(event, x, points, row)",
+    "type": "function(event, x, points, row, seriesName)",
     "description": "When set, this callback gets called every time a new point is highlighted.",
     "parameters": [
       ["event", "the JavaScript mousemove event"],
       ["x", "the x-coordinate of the highlighted points"],
       ["points", "an array of highlighted points: <code>[ {name: 'series', yval: y-value}, &hellip; ]</code>"],
-      ["row", "???"]
+      ["row", "integer index of the highlighted row in the data table, starting from 0"],
+      ["seriesName", "name of the highlighted series, only present if highlightSeriesOpts is set."]
     ]
   },
   "drawHighlightPointCallback": {
@@ -138,6 +139,18 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
         should constrain drawing to within pointSize pixels from (cx, cy) \
         Also see <a href='#drawPointCallback'>drawPointCallback</a>"
   },
+  "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 }."
+  },
+  "highlightSeriesBackgroundAlpha": {
+    "default": "0.5",
+    "labels": ["Interactive Elements"],
+    "type": "float",
+    "description": "Fade the background while highlighting series. 1=fully visible background (disable fading), 0=hiddden background (show highlighted series only)."
+  },
   "includeZero": {
     "default": "false",
     "labels": ["Axis display"],
@@ -354,7 +367,7 @@ Dygraph.OPTIONS_REFERENCE =  // <JSON>
   "strokeWidth": {
     "default": "1.0",
     "labels": ["Data Line display"],
-    "type": "integer",
+    "type": "float",
     "example": "0.5, 2.0",
     "description": "The width of the lines connecting data points. This can be used to increase the contrast or some graphs."
   },
@@ -365,6 +378,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": "float",
+    "example": "1.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 4b4bd30..3502c31 100644 (file)
@@ -186,6 +186,8 @@ Dygraph.dateAxisFormatter = function(date, granularity) {
 // Default attribute values.
 Dygraph.DEFAULT_ATTRS = {
   highlightCircleSize: 3,
+  highlightSeriesOpts: null,
+  highlightSeriesBackgroundAlpha: 0.5,
 
   labelsDivWidth: 250,
   labelsDivStyles: {
@@ -202,6 +204,8 @@ Dygraph.DEFAULT_ATTRS = {
   sigFigs: null,
 
   strokeWidth: 1.0,
+  strokeBorderWidth: 0,
+  strokeBorderColor: "white",
 
   axisTickSize: 3,
   axisLabelFontSize: 14,
@@ -398,6 +402,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 +457,31 @@ 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 (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;
 };
 
 /**
@@ -1465,74 +1483,175 @@ Dygraph.prototype.doAnimatedZoom = function(oldXRange, newXRange, oldYRanges, ne
 };
 
 /**
- * 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.
- * @param {Object} event The mousemove event from the browser.
- * @private
+ * Get the current graph's area object.
+ *
+ * Returns: {x, y, w, h}
  */
-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;
+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];
+};
 
-  var lastx = -1;
-  var i;
-
-  // Loop through all the points and find the date nearest to our current
-  // location.
-  var minDist = 1e+100;
+/**
+ * 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;
-  for (i = 0; i < points.length; i++) {
+  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 - canvasx);
-    if (dist > minDist) continue;
-    minDist = dist;
+    var dist = Math.abs(point.canvasx - domX);
+    if (minDistX !== null && dist >= minDistX) continue;
+    minDistX = dist;
     idx = i;
   }
-  if (idx >= 0) lastx = points[idx].xval;
+  return this.idxToRow_(idx);
+};
 
-  // 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]);
+/**
+ * Given canvas X,Y coordinates, find the closest point.
+ *
+ * This finds the individual data point across all visible series
+ * that's closest to the supplied DOM coordinates using the standard
+ * Euclidean X,Y distance.
+ *
+ * @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.
+ *
+ * This first finds the X data point closest to the supplied DOM X coordinate,
+ * then finds the series which puts the Y coordinate on top of its filled area,
+ * using linear interpolation between adjacent point pairs.
+ *
+ * @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 {
-    // 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);
+    } 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);
       }
     }
-    this.selPoints_.reverse();
+    // 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
+  };
+};
 
-  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));
+/**
+ * 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.
+ * @param {Object} event The mousemove event from the browser.
+ * @private
+ */
+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;
+
+  var canvasCoords = this.eventToDomCoords(event);
+  var canvasx = canvasCoords[0];
+  var canvasy = canvasCoords[1];
+
+  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 {
+    var idx = this.findClosestRow(canvasx);
+    selectionChanged = this.setSelection(idx);
   }
 
-  // Save last x position for callbacks.
-  this.lastx_ = lastx;
-
-  this.updateSelection_();
+  var callback = this.attr_("highlightCallback");
+  if (callback && selectionChanged) {
+    callback(event, this.lastx_, this.selPoints_, this.lastRow_, this.highlightSet_);
+  }
 };
 
 /**
@@ -1663,7 +1782,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;
@@ -1691,9 +1810,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;
 };
@@ -1725,16 +1845,70 @@ 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 = 1.0 - this.attr_('highlightSeriesBackgroundAlpha');
+    if (alpha) {
+      // Activating background fade includes an animation effect for a gradual
+      // fade. TODO(klausw): make this independently configurable if it causes
+      // issues? Use a shared preference to control animations?
+      var animateBackgroundFade = true;
+      if (animateBackgroundFade) {
+        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_);
+    this.plotter_._drawLine(ctx, setIdx);
+  } else if (this.previousVerticalX_ >= 0) {
     // Determine the maximum highlight circle size.
     var maxCircleSize = 0;
     var labels = this.attr_('labels');
@@ -1788,17 +1962,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) {
@@ -1812,15 +1996,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;
 };
 
 /**
@@ -1844,10 +2039,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;
 };
 
 /**
@@ -1868,6 +2070,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
@@ -2141,10 +2347,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);
@@ -3330,6 +3538,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
diff --git a/tests/series-highlight.html b/tests/series-highlight.html
new file mode 100644 (file)
index 0000000..94e09d6
--- /dev/null
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7; IE=EmulateIE9">
+    <title>Series highlighting</title>
+    <!--[if IE]>
+    <script type="text/javascript" src="../excanvas.js"></script>
+    <![endif]-->
+    <!--
+    For production (minified) code, use:
+    <script type="text/javascript" src="dygraph-combined.js"></script>
+    -->
+    <script type="text/javascript" src="../dygraph-dev.js"></script>
+
+    <style type='text/css'>
+      .few .dygraph-legend > span.highlight { border: 1px solid grey; }
+      .many .dygraph-legend > span { display: none; }
+      .many .dygraph-legend > span.highlight { display: inline; }
+    </style>
+  </head>
+  <body>
+    <h2>Series highlighting demo</h2>
+<script type='text/javascript'>
+
+var getData = function(numSeries, numRows, isStacked) {
+  var data = [];
+
+  for (var j = 0; j < numRows; ++j) {
+    data[j] = [j];
+  }
+  for (var i = 0; i < numSeries; ++i) {
+    var val = 0;
+    for (var j = 0; j < numRows; ++j) {
+      if (isStacked) {
+        val = Math.random();
+      } else {
+        val += Math.random() - 0.5;
+      }
+      data[j][i + 1] = val;
+    }
+  }
+  return data;
+};
+
+var makeGraph = function(className, numSeries, numRows, isStacked) {
+  var div = document.createElement('div');
+  div.className = className;
+  div.style.display = 'inline-block';
+  document.body.appendChild(div);
+
+  var labels = ['x'];
+  for (var i = 0; i < numSeries; ++i) {
+    var label = '' + i;
+    label = 's' + '000'.substr(label.length) + label;
+    labels[i + 1] = label;
+  }
+  var g = new Dygraph(
+      div,
+      getData(numSeries, numRows, isStacked),
+      {
+        width: 480,
+        height: 320,
+        labels: labels.slice(),
+        stackedGraph: isStacked,
+
+        highlightCircleSize: 2,
+        strokeWidth: 1,
+        strokeBorderWidth: isStacked ? null : 1,
+
+        highlightSeriesOpts: {
+          strokeWidth: 3,
+          strokeBorderWidth: 1,
+          highlightCircleSize: 5,
+        },
+      });
+  g.setSelection(false, 's005');
+  //console.log(g);
+};
+
+makeGraph("few", 20, 50, false);
+makeGraph("few", 10, 20, true);
+makeGraph("many", 75, 50, false);
+makeGraph("many", 40, 50, true);
+</script>
+</body>
+</html>