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

1  2 
dygraph.js

diff --combined dygraph.js
@@@ -186,6 -186,8 +186,8 @@@ Dygraph.dateAxisFormatter = function(da
  // Default attribute values.
  Dygraph.DEFAULT_ATTRS = {
    highlightCircleSize: 3,
+   highlightSeriesOpts: null,
+   highlightSeriesBackgroundAlpha: 0.5,
  
    labelsDivWidth: 250,
    labelsDivStyles: {
    sigFigs: null,
  
    strokeWidth: 1.0,
+   strokeBorderWidth: 0,
+   strokeBorderColor: "white",
  
    axisTickSize: 3,
    axisLabelFontSize: 14,
@@@ -398,6 -402,7 +402,7 @@@ Dygraph.prototype.__init__ = function(d
  
    this.boundaryIds_ = [];
    this.setIndexByName_ = {};
+   this.datasetIndex_ = [];
  
    // Create the containing DIV and other interactive elements
    this.createInterface_();
@@@ -452,18 -457,31 +457,31 @@@ Dygraph.prototype.attr_ = function(name
      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;
  };
  
  /**
@@@ -1003,11 -1021,7 +1021,11 @@@ Dygraph.prototype.createStatusMessage_ 
      div.className = "dygraph-legend";
      for (var name in messagestyle) {
        if (messagestyle.hasOwnProperty(name)) {
 -        div.style[name] = messagestyle[name];
 +        try {
 +          div.style[name] = messagestyle[name];
 +        } catch (e) {
 +          this.warn("You are using unsupported css properties for your browser in labelsDivStyles");
 +        }
        }
      }
      this.graphDiv.appendChild(div);
@@@ -1465,74 -1479,175 +1483,175 @@@ Dygraph.prototype.doAnimatedZoom = func
  };
  
  /**
-  * 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 -1778,7 +1782,7 @@@ Dygraph.prototype.generateLegendHTML_ 
        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;
      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 -1841,70 +1845,70 @@@ Dygraph.prototype.setLegendHTML_ = func
    }
  };
  
+ 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');
  
        var circleSize = this.attr_('highlightCircleSize', pt.name);
        var callback = this.attr_("drawHighlightPointCallback", pt.name);
 +      var color = this.plotter_.colors[pt.name];
        if (!callback) {
          callback = Dygraph.Circles.DEFAULT;
        }
 +      ctx.lineWidth = this.attr_('strokeWidth', pt.name);
 +      ctx.strokeStyle = color;
 +      ctx.fillStyle = color;
        callback(this.g, pt.name, ctx, canvasx, pt.canvasy,
 -          this.plotter_.colors[pt.name], circleSize);
 +          color, circleSize);
      }
      ctx.restore();
  
   * 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) {
        }
        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 -2031,17 +2039,17 @@@ Dygraph.prototype.mouseOut_ = function(
   */
  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 -2062,10 +2070,10 @@@ Dygraph.prototype.getSelection = functi
    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 -2339,12 +2347,12 @@@ Dygraph.prototype.drawGraph_ = function
    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 -3530,15 +3538,15 @@@ Dygraph.prototype.indexFromSetName = fu
  };
  
  /**
+  * 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