Move code into src/
[dygraphs.git] / src / extras / hairlines.js
diff --git a/src/extras/hairlines.js b/src/extras/hairlines.js
new file mode 100644 (file)
index 0000000..904e432
--- /dev/null
@@ -0,0 +1,459 @@
+/**
+ * @license
+ * Copyright 2013 Dan Vanderkam (danvdk@gmail.com)
+ * MIT-licensed (http://opensource.org/licenses/MIT)
+ *
+ * Note: This plugin requires jQuery and jQuery UI Draggable.
+ *
+ * See high-level documentation at
+ * https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit#
+ */
+
+/*global Dygraph:false */
+
+Dygraph.Plugins.Hairlines = (function() {
+
+"use strict";
+
+/**
+ * @typedef {
+ *   xval:  number,      // x-value (i.e. millis or a raw number)
+ *   interpolated: bool,  // alternative is to snap to closest
+ *   lineDiv: !Element    // vertical hairline div
+ *   infoDiv: !Element    // div containing info about the nearest points
+ *   selected: boolean    // whether this hairline is selected
+ * } Hairline
+ */
+
+// We have to wait a few ms after clicks to give the user a chance to
+// double-click to unzoom. This sets that delay period.
+var CLICK_DELAY_MS = 300;
+
+var hairlines = function(opt_options) {
+  /* @type {!Array.<!Hairline>} */
+  this.hairlines_ = [];
+
+  // Used to detect resizes (which require the divs to be repositioned).
+  this.lastWidth_ = -1;
+  this.lastHeight = -1;
+  this.dygraph_ = null;
+
+  this.addTimer_ = null;
+  opt_options = opt_options || {};
+
+  this.divFiller_ = opt_options['divFiller'] || null;
+};
+
+hairlines.prototype.toString = function() {
+  return "Hairlines Plugin";
+};
+
+hairlines.prototype.activate = function(g) {
+  this.dygraph_ = g;
+  this.hairlines_ = [];
+
+  return {
+    didDrawChart: this.didDrawChart,
+    click: this.click,
+    dblclick: this.dblclick,
+    dataDidUpdate: this.dataDidUpdate
+  };
+};
+
+hairlines.prototype.detachLabels = function() {
+  for (var i = 0; i < this.hairlines_.length; i++) {
+    var h = this.hairlines_[i];
+    $(h.lineDiv).remove();
+    $(h.infoDiv).remove();
+    this.hairlines_[i] = null;
+  }
+  this.hairlines_ = [];
+};
+
+hairlines.prototype.hairlineWasDragged = function(h, event, ui) {
+  var area = this.dygraph_.getArea();
+  var oldXVal = h.xval;
+  h.xval = this.dygraph_.toDataXCoord(ui.position.left);
+  this.moveHairlineToTop(h);
+  this.updateHairlineDivPositions();
+  this.updateHairlineInfo();
+  this.updateHairlineStyles();
+  $(this).triggerHandler('hairlineMoved', {
+    oldXVal: oldXVal,
+    newXVal: h.xval
+  });
+  $(this).triggerHandler('hairlinesChanged', {});
+};
+
+// This creates the hairline object and returns it.
+// It does not position it and does not attach it to the chart.
+hairlines.prototype.createHairline = function(props) {
+  var h;
+  var self = this;
+
+  var $lineContainerDiv = $('<div/>').css({
+      'width': '6px',
+      'margin-left': '-3px',
+      'position': 'absolute',
+      'z-index': '10'
+    })
+    .addClass('dygraph-hairline');
+
+  var $lineDiv = $('<div/>').css({
+    'width': '1px',
+    'position': 'relative',
+    'left': '3px',
+    'background': 'black',
+    'height': '100%'
+  });
+  $lineDiv.appendTo($lineContainerDiv);
+
+  var $infoDiv = $('#hairline-template').clone().removeAttr('id').css({
+      'position': 'absolute'
+    })
+    .show();
+
+  // Surely there's a more jQuery-ish way to do this!
+  $([$infoDiv.get(0), $lineContainerDiv.get(0)])
+    .draggable({
+      'axis': 'x',
+      'drag': function(event, ui) {
+        self.hairlineWasDragged(h, event, ui);
+      }
+      // TODO(danvk): set cursor here
+    });
+
+  h = $.extend({
+    interpolated: true,
+    selected: false,
+    lineDiv: $lineContainerDiv.get(0),
+    infoDiv: $infoDiv.get(0)
+  }, props);
+
+  var that = this;
+  $infoDiv.on('click', '.hairline-kill-button', function(e) {
+    that.removeHairline(h);
+    $(that).triggerHandler('hairlineDeleted', {
+      xval: h.xval
+    });
+    $(that).triggerHandler('hairlinesChanged', {});
+    e.stopPropagation();  // don't want .click() to trigger, below.
+  }).on('click', function() {
+    that.moveHairlineToTop(h);
+  });
+
+  return h;
+};
+
+// Moves a hairline's divs to the top of the z-ordering.
+hairlines.prototype.moveHairlineToTop = function(h) {
+  var div = this.dygraph_.graphDiv;
+  $(h.infoDiv).appendTo(div);
+  $(h.lineDiv).appendTo(div);
+
+  var idx = this.hairlines_.indexOf(h);
+  this.hairlines_.splice(idx, 1);
+  this.hairlines_.push(h);
+};
+
+// Positions existing hairline divs.
+hairlines.prototype.updateHairlineDivPositions = function() {
+  var g = this.dygraph_;
+  var layout = this.dygraph_.getArea();
+  var chartLeft = layout.x, chartRight = layout.x + layout.w;
+  var div = this.dygraph_.graphDiv;
+  var pos = Dygraph.findPos(div);
+  var box = [layout.x + pos.x, layout.y + pos.y];
+  box.push(box[0] + layout.w);
+  box.push(box[1] + layout.h);
+
+  $.each(this.hairlines_, function(idx, h) {
+    var left = g.toDomXCoord(h.xval);
+    h.domX = left;  // See comments in this.dataDidUpdate
+    $(h.lineDiv).css({
+      'left': left + 'px',
+      'top': layout.y + 'px',
+      'height': layout.h + 'px'
+    });  // .draggable("option", "containment", box);
+    $(h.infoDiv).css({
+      'left': left + 'px',
+      'top': layout.y + 'px',
+    }).draggable("option", "containment", box);
+
+    var visible = (left >= chartLeft && left <= chartRight);
+    $([h.infoDiv, h.lineDiv]).toggle(visible);
+  });
+};
+
+// Sets styles on the hairline (i.e. "selected")
+hairlines.prototype.updateHairlineStyles = function() {
+  $.each(this.hairlines_, function(idx, h) {
+    $([h.infoDiv, h.lineDiv]).toggleClass('selected', h.selected);
+  });
+};
+
+// Find prevRow and nextRow such that
+// g.getValue(prevRow, 0) <= xval
+// g.getValue(nextRow, 0) >= xval
+// g.getValue({prev,next}Row, col) != null, NaN or undefined
+// and there's no other row such that:
+//   g.getValue(prevRow, 0) < g.getValue(row, 0) < g.getValue(nextRow, 0)
+//   g.getValue(row, col) != null, NaN or undefined.
+// Returns [prevRow, nextRow]. Either can be null (but not both).
+hairlines.findPrevNextRows = function(g, xval, col) {
+  var prevRow = null, nextRow = null;
+  var numRows = g.numRows();
+  for (var row = 0; row < numRows; row++) {
+    var yval = g.getValue(row, col);
+    if (yval === null || yval === undefined || isNaN(yval)) continue;
+
+    var rowXval = g.getValue(row, 0);
+    if (rowXval <= xval) prevRow = row;
+
+    if (rowXval >= xval) {
+      nextRow = row;
+      break;
+    }
+  }
+
+  return [prevRow, nextRow];
+};
+
+// Fills out the info div based on current coordinates.
+hairlines.prototype.updateHairlineInfo = function() {
+  var mode = 'closest';
+
+  var g = this.dygraph_;
+  var xRange = g.xAxisRange();
+  var that = this;
+  $.each(this.hairlines_, function(idx, h) {
+    // To use generateLegendHTML, we synthesize an array of selected points.
+    var selPoints = [];
+    var labels = g.getLabels();
+    var row, prevRow, nextRow;
+
+    if (!h.interpolated) {
+      // "closest point" mode.
+      // TODO(danvk): make findClosestRow method public
+      row = g.findClosestRow(g.toDomXCoord(h.xval));
+      for (var i = 1; i < g.numColumns(); i++) {
+        selPoints.push({
+          canvasx: 1,  // TODO(danvk): real coordinate
+          canvasy: 1,  // TODO(danvk): real coordinate
+          xval: h.xval,
+          yval: g.getValue(row, i),
+          name: labels[i]
+        });
+      }
+    } else {
+      // "interpolated" mode.
+      for (var i = 1; i < g.numColumns(); i++) {
+        var prevNextRow = hairlines.findPrevNextRows(g, h.xval, i);
+        prevRow = prevNextRow[0], nextRow = prevNextRow[1];
+
+        // For x-values outside the domain, interpolate "between" the extreme
+        // point and itself.
+        if (prevRow === null) prevRow = nextRow;
+        if (nextRow === null) nextRow = prevRow;
+
+        // linear interpolation
+        var prevX = g.getValue(prevRow, 0),
+            nextX = g.getValue(nextRow, 0),
+            prevY = g.getValue(prevRow, i),
+            nextY = g.getValue(nextRow, i),
+            frac = prevRow == nextRow ? 0 : (h.xval - prevX) / (nextX - prevX),
+            yval = frac * nextY + (1 - frac) * prevY;
+
+        selPoints.push({
+          canvasx: 1,  // TODO(danvk): real coordinate
+          canvasy: 1,  // TODO(danvk): real coordinate
+          xval: h.xval,
+          yval: yval,
+          prevRow: prevRow,
+          nextRow: nextRow,
+          name: labels[i]
+        });
+      }
+    }
+
+    if (that.divFiller_) {
+      that.divFiller_(h.infoDiv, {
+        closestRow: row,
+        points: selPoints,
+        hairline: that.createPublicHairline_(h),
+        dygraph: g
+      });
+    } else {
+      var html = Dygraph.Plugins.Legend.generateLegendHTML(g, h.xval, selPoints, 10);
+      $('.hairline-legend', h.infoDiv).html(html);
+    }
+  });
+};
+
+// After a resize, the hairline divs can get dettached from the chart.
+// This reattaches them.
+hairlines.prototype.attachHairlinesToChart_ = function() {
+  var div = this.dygraph_.graphDiv;
+  $.each(this.hairlines_, function(idx, h) {
+    $([h.lineDiv, h.infoDiv]).appendTo(div);
+  });
+};
+
+// Deletes a hairline and removes it from the chart.
+hairlines.prototype.removeHairline = function(h) {
+  var idx = this.hairlines_.indexOf(h);
+  if (idx >= 0) {
+    this.hairlines_.splice(idx, 1);
+    $([h.lineDiv, h.infoDiv]).remove();
+  } else {
+    Dygraph.warn('Tried to remove non-existent hairline.');
+  }
+};
+
+hairlines.prototype.didDrawChart = function(e) {
+  var g = e.dygraph;
+
+  // Early out in the (common) case of zero hairlines.
+  if (this.hairlines_.length === 0) return;
+
+  this.updateHairlineDivPositions();
+  this.attachHairlinesToChart_();
+  this.updateHairlineInfo();
+  this.updateHairlineStyles();
+};
+
+hairlines.prototype.dataDidUpdate = function(e) {
+  // When the data in the chart updates, the hairlines should stay in the same
+  // position on the screen. didDrawChart stores a domX parameter for each
+  // hairline. We use that to reposition them on data updates.
+  var g = this.dygraph_;
+  $.each(this.hairlines_, function(idx, h) {
+    if (h.hasOwnProperty('domX')) {
+      h.xval = g.toDataXCoord(h.domX);
+    }
+  });
+};
+
+hairlines.prototype.click = function(e) {
+  if (this.addTimer_) {
+    // Another click is in progress; ignore this one.
+    return;
+  }
+
+  var area = e.dygraph.getArea();
+  var xval = this.dygraph_.toDataXCoord(e.canvasx);
+
+  var that = this;
+  this.addTimer_ = setTimeout(function() {
+    that.addTimer_ = null;
+    that.hairlines_.push(that.createHairline({xval: xval}));
+
+    that.updateHairlineDivPositions();
+    that.updateHairlineInfo();
+    that.updateHairlineStyles();
+    that.attachHairlinesToChart_();
+
+    $(that).triggerHandler('hairlineCreated', {
+      xval: xval
+    });
+    $(that).triggerHandler('hairlinesChanged', {});
+  }, CLICK_DELAY_MS);
+};
+
+hairlines.prototype.dblclick = function(e) {
+  if (this.addTimer_) {
+    clearTimeout(this.addTimer_);
+    this.addTimer_ = null;
+  }
+};
+
+hairlines.prototype.destroy = function() {
+  this.detachLabels();
+};
+
+
+// Public API
+
+/**
+ * This is a restricted view of this.hairlines_ which doesn't expose
+ * implementation details like the handle divs.
+ *
+ * @typedef {
+ *   xval:  number,       // x-value (i.e. millis or a raw number)
+ *   interpolated: bool,  // alternative is to snap to closest
+ *   selected: bool       // whether the hairline is selected.
+ * } PublicHairline
+ */
+
+/**
+ * @param {!Hairline} h Internal hairline.
+ * @return {!PublicHairline} Restricted public view of the hairline.
+ */
+hairlines.prototype.createPublicHairline_ = function(h) {
+  return {
+    xval: h.xval,
+    interpolated: h.interpolated,
+    selected: h.selected
+  };
+};
+
+/**
+ * @return {!Array.<!PublicHairline>} The current set of hairlines, ordered
+ *     from back to front.
+ */
+hairlines.prototype.get = function() {
+  var result = [];
+  for (var i = 0; i < this.hairlines_.length; i++) {
+    var h = this.hairlines_[i];
+    result.push(this.createPublicHairline_(h));
+  }
+  return result;
+};
+
+/**
+ * Calling this will result in a hairlinesChanged event being triggered, no
+ * matter whether it consists of additions, deletions, moves or no changes at
+ * all.
+ *
+ * @param {!Array.<!PublicHairline>} hairlines The new set of hairlines,
+ *     ordered from back to front.
+ */
+hairlines.prototype.set = function(hairlines) {
+  // Re-use divs from the old hairlines array so far as we can.
+  // They're already correctly z-ordered.
+  var anyCreated = false;
+  for (var i = 0; i < hairlines.length; i++) {
+    var h = hairlines[i];
+
+    if (this.hairlines_.length > i) {
+      this.hairlines_[i].xval = h.xval;
+      this.hairlines_[i].interpolated = h.interpolated;
+      this.hairlines_[i].selected = h.selected;
+    } else {
+      this.hairlines_.push(this.createHairline({
+        xval: h.xval,
+        interpolated: h.interpolated,
+        selected: h.selected
+      }));
+      anyCreated = true;
+    }
+  }
+
+  // If there are any remaining hairlines, destroy them.
+  while (hairlines.length < this.hairlines_.length) {
+    this.removeHairline(this.hairlines_[hairlines.length]);
+  }
+
+  this.updateHairlineDivPositions();
+  this.updateHairlineInfo();
+  this.updateHairlineStyles();
+  if (anyCreated) {
+    this.attachHairlinesToChart_();
+  }
+
+  $(this).triggerHandler('hairlinesChanged', {});
+};
+
+return hairlines;
+
+})();