Hairlines/Super-annotations plugins + test master+hairlines
authorDan Vanderkam <danvdk@gmail.com>
Sat, 22 Nov 2014 05:59:00 +0000 (00:59 -0500)
committerDan Vanderkam <danvdk@gmail.com>
Sat, 22 Nov 2014 06:05:19 +0000 (01:05 -0500)
extras/hairlines.js [new file with mode: 0644]
extras/super-annotations.js [new file with mode: 0644]
tests/hairlines.html [new file with mode: 0644]

diff --git a/extras/hairlines.js b/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;
+
+})();
diff --git a/extras/super-annotations.js b/extras/super-annotations.js
new file mode 100644 (file)
index 0000000..7697beb
--- /dev/null
@@ -0,0 +1,474 @@
+/**
+ * @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.SuperAnnotations = (function() {
+
+"use strict";
+
+/**
+ * These are just the basic requirements -- annotations can have whatever other
+ * properties the code that displays them wants them to have.
+ *
+ * @typedef {
+ *   xval:  number,      // x-value (i.e. millis or a raw number)
+ *   series: string,     // series name
+ *   yFrac: ?number,     // y-positioning. Default is a few px above the point.
+ *   lineDiv: !Element   // vertical div connecting point to info div.
+ *   infoDiv: !Element   // div containing info about the annotation.
+ * } Annotation
+ */
+
+var annotations = function(opt_options) {
+  /* @type {!Array.<!Annotation>} */
+  this.annotations_ = [];
+  // Used to detect resizes (which require the divs to be repositioned).
+  this.lastWidth_ = -1;
+  this.lastHeight = -1;
+  this.dygraph_ = null;
+
+  opt_options = opt_options || {};
+  this.defaultAnnotationProperties_ = $.extend({
+    'text': 'Description'
+  }, opt_options['defaultAnnotationProperties']);
+};
+
+annotations.prototype.toString = function() {
+  return "SuperAnnotations Plugin";
+};
+
+annotations.prototype.activate = function(g) {
+  this.dygraph_ = g;
+  this.annotations_ = [];
+
+  return {
+    didDrawChart: this.didDrawChart,
+    pointClick: this.pointClick  // TODO(danvk): implement in dygraphs
+  };
+};
+
+annotations.prototype.detachLabels = function() {
+  for (var i = 0; i < this.annotations_.length; i++) {
+    var a = this.annotations_[i];
+    $(a.lineDiv).remove();
+    $(a.infoDiv).remove();
+    this.annotations_[i] = null;
+  }
+  this.annotations_ = [];
+};
+
+annotations.prototype.annotationWasDragged = function(a, event, ui) {
+  var g = this.dygraph_;
+  var area = g.getArea();
+  var oldYFrac = a.yFrac;
+
+  var infoDiv = a.infoDiv;
+  var newYFrac = ((infoDiv.offsetTop + infoDiv.offsetHeight) - area.y) / area.h;
+  if (newYFrac == oldYFrac) return;
+
+  a.yFrac = newYFrac;
+
+  this.moveAnnotationToTop(a);
+  this.updateAnnotationDivPositions();
+  this.updateAnnotationInfo();
+  $(this).triggerHandler('annotationMoved', {
+    annotation: a,
+    oldYFrac: oldYFrac,
+    newYFrac: a.yFrac
+  });
+  $(this).triggerHandler('annotationsChanged', {});
+};
+
+annotations.prototype.makeAnnotationEditable = function(a) {
+  if (a.editable == true) return;
+  this.moveAnnotationToTop(a);
+
+  // Note: we have to fill out the HTML ourselves because
+  // updateAnnotationInfo() won't touch editable annotations.
+  a.editable = true;
+  var editableTemplateDiv = $('#annotation-editable-template').get(0);
+  a.infoDiv.innerHTML = this.getTemplateHTML(editableTemplateDiv, a);
+  $(a.infoDiv).toggleClass('editable', !!a.editable);
+  $(this).triggerHandler('beganEditAnnotation', a);
+};
+
+// This creates the hairline object and returns it.
+// It does not position it and does not attach it to the chart.
+annotations.prototype.createAnnotation = function(a) {
+  var self = this;
+
+  var color = this.getColorForSeries_(a.series);
+
+  var $lineDiv = $('<div/>').css({
+    'width': '1px',
+    'left': '3px',
+    'background': 'black',
+    'height': '100%',
+    'position': 'absolute',
+    // TODO(danvk): use border-color here for consistency?
+    'background-color': color,
+    'z-index': 10
+  }).addClass('dygraph-annotation-line');
+
+  var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({
+      'position': 'absolute',
+      'border-color': color,
+      'z-index': 10
+    })
+    .show();
+
+  $.extend(a, {
+    lineDiv: $lineDiv.get(0),
+    infoDiv: $infoDiv.get(0)
+  });
+
+  var that = this;
+
+  $infoDiv.draggable({
+    'start': function(event, ui) {
+      $(this).css({'bottom': ''});
+      a.isDragging = true;
+    },
+    'drag': function(event, ui) {
+      self.annotationWasDragged(a, event, ui);
+    },
+    'stop': function(event, ui) {
+      $(this).css({'top': ''});
+      a.isDragging = false;
+      self.updateAnnotationDivPositions();
+    },
+    'axis': 'y',
+    'containment': 'parent'
+  });
+
+  // TODO(danvk): use 'on' instead of delegate/dblclick
+  $infoDiv.on('click', '.annotation-kill-button', function() {
+    that.removeAnnotation(a);
+    $(that).triggerHandler('annotationDeleted', a);
+    $(that).triggerHandler('annotationsChanged', {});
+  });
+
+  $infoDiv.on('dblclick', function() {
+    that.makeAnnotationEditable(a);
+  });
+  $infoDiv.on('click', '.annotation-update', function() {
+    self.extractUpdatedProperties_($infoDiv.get(0), a);
+    a.editable = false;
+    self.updateAnnotationInfo();
+    $(that).triggerHandler('annotationEdited', a);
+    $(that).triggerHandler('annotationsChanged', {});
+  });
+  $infoDiv.on('click', '.annotation-cancel', function() {
+    a.editable = false;
+    self.updateAnnotationInfo();
+    $(that).triggerHandler('cancelEditAnnotation', a);
+  });
+
+  return a;
+};
+
+// Find the index of a point in a series.
+// Returns a 2-element array, [row, col], which can be used with
+// dygraph.getValue() to get the value at this point.
+// Returns null if there's no match.
+annotations.prototype.findPointIndex_ = function(series, xval) {
+  var col = this.dygraph_.getLabels().indexOf(series);
+  if (col == -1) return null;
+
+  var lowIdx = 0, highIdx = this.dygraph_.numRows() - 1;
+  while (lowIdx <= highIdx) {
+    var idx = Math.floor((lowIdx + highIdx) / 2);
+    var xAtIdx = this.dygraph_.getValue(idx, 0);
+    if (xAtIdx == xval) {
+      return [idx, col];
+    } else if (xAtIdx < xval) {
+      lowIdx = idx + 1;
+    } else {
+      highIdx = idx - 1;
+    }
+  }
+  return null;
+};
+
+annotations.prototype.getColorForSeries_ = function(series) {
+  var colors = this.dygraph_.getColors();
+  var col = this.dygraph_.getLabels().indexOf(series);
+  if (col == -1) return null;
+
+  return colors[(col - 1) % colors.length];
+};
+
+// Moves a hairline's divs to the top of the z-ordering.
+annotations.prototype.moveAnnotationToTop = function(a) {
+  var div = this.dygraph_.graphDiv;
+  $(a.infoDiv).appendTo(div);
+  $(a.lineDiv).appendTo(div);
+
+  var idx = this.annotations_.indexOf(a);
+  this.annotations_.splice(idx, 1);
+  this.annotations_.push(a);
+};
+
+// Positions existing hairline divs.
+annotations.prototype.updateAnnotationDivPositions = function() {
+  var layout = this.dygraph_.getArea();
+  var chartLeft = layout.x, chartRight = layout.x + layout.w;
+  var chartTop = layout.y, chartBottom = layout.y + layout.h;
+  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);
+
+  var g = this.dygraph_;
+
+  var that = this;
+  $.each(this.annotations_, function(idx, a) {
+    var row_col = that.findPointIndex_(a.series, a.xval);
+    if (row_col == null) {
+      $([a.lineDiv, a.infoDiv]).hide();
+      return;
+    } else {
+      // TODO(danvk): only do this if they're invisible?
+      $([a.lineDiv, a.infoDiv]).show();
+    }
+    var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1]));
+    var x = xy[0], pointY = xy[1];
+
+    var lineHeight = 6;  // TODO(danvk): option?
+
+    var y = pointY;
+    if (a.yFrac !== undefined) {
+      y = layout.y + layout.h * a.yFrac;
+    } else {
+      y -= lineHeight;
+    }
+
+    var lineHeight = y < pointY ? (pointY - y) : (y - pointY - a.infoDiv.offsetHeight);
+    $(a.lineDiv).css({
+      'left': x + 'px',
+      'top': Math.min(y, pointY) + 'px',
+      'height': lineHeight + 'px'
+    });
+    $(a.infoDiv).css({
+      'left': x + 'px',
+    });
+    if (!a.isDragging) {
+      // jQuery UI draggable likes to set 'top', whereas superannotations sets
+      // 'bottom'. Setting both will make the annotation grow and contract as
+      // the user drags it, which looks bad.
+      $(a.infoDiv).css({
+        'bottom': (div.offsetHeight - y) + 'px'
+      })  //.draggable("option", "containment", box);
+
+      var visible = (x >= chartLeft && x <= chartRight) &&
+                    (pointY >= chartTop && pointY <= chartBottom);
+      $([a.infoDiv, a.lineDiv]).toggle(visible);
+    }
+  });
+};
+
+// Fills out the info div based on current coordinates.
+annotations.prototype.updateAnnotationInfo = function() {
+  var g = this.dygraph_;
+
+  var that = this;
+  var templateDiv = $('#annotation-template').get(0);
+  $.each(this.annotations_, function(idx, a) {
+    // We should never update an editable div -- doing so may kill unsaved
+    // edits to an annotation.
+    $(a.infoDiv).toggleClass('editable', !!a.editable);
+    if (a.editable) return;
+    a.infoDiv.innerHTML = that.getTemplateHTML(templateDiv, a);
+  });
+};
+
+/**
+ * @param {!Annotation} a Internal annotation
+ * @return {!PublicAnnotation} a view of the annotation for the public API.
+ */
+annotations.prototype.createPublicAnnotation_ = function(a, opt_props) {
+  var displayAnnotation = $.extend({}, a, opt_props);
+  delete displayAnnotation['infoDiv'];
+  delete displayAnnotation['lineDiv'];
+  delete displayAnnotation['isDragging'];
+  delete displayAnnotation['editable'];
+  return displayAnnotation;
+};
+
+// Fill out a div using the values in the annotation object.
+// The div's html is expected to have text of the form "{{key}}"
+annotations.prototype.getTemplateHTML = function(div, a) {
+  var g = this.dygraph_;
+  var row_col = this.findPointIndex_(a.series, a.xval);
+  if (row_col == null) return;  // perhaps it's no longer a real point?
+  var row = row_col[0];
+  var col = row_col[1];
+
+  var yOptView = g.optionsViewForAxis_('y1');  // TODO: support secondary, too
+  var xvf = g.getOptionForAxis('valueFormatter', 'x');
+
+  var x = xvf(a.xval);
+  var y = g.getOption('valueFormatter', a.series)(
+      g.getValue(row, col), yOptView);
+
+  var displayAnnotation = this.createPublicAnnotation_(a, {x:x, y:y});
+  var html = div.innerHTML;
+  for (var k in displayAnnotation) {
+    var v = displayAnnotation[k];
+    if (typeof(v) == 'object') continue;  // e.g. infoDiv or lineDiv
+    html = html.replace(new RegExp('\{\{' + k + '\}\}', 'g'), v);
+  }
+  return html;
+};
+
+// Update the annotation object by looking for elements with a 'dg-ann-field'
+// attribute. For example, <input type='text' dg-ann-field='text' /> will have
+// its value placed in the 'text' attribute of the annotation.
+annotations.prototype.extractUpdatedProperties_ = function(div, a) {
+  $(div).find('[dg-ann-field]').each(function(idx, el) {
+    var k = $(el).attr('dg-ann-field');
+    var v = $(el).val();
+    a[k] = v;
+  });
+};
+
+// After a resize, the hairline divs can get dettached from the chart.
+// This reattaches them.
+annotations.prototype.attachAnnotationsToChart_ = function() {
+  var div = this.dygraph_.graphDiv;
+  $.each(this.annotations_, function(idx, a) {
+    // Re-attaching an editable div to the DOM can clear its focus.
+    // This makes typing really difficult!
+    if (a.editable) return;
+
+    $([a.lineDiv, a.infoDiv]).appendTo(div);
+  });
+};
+
+// Deletes a hairline and removes it from the chart.
+annotations.prototype.removeAnnotation = function(a) {
+  var idx = this.annotations_.indexOf(a);
+  if (idx >= 0) {
+    this.annotations_.splice(idx, 1);
+    $([a.lineDiv, a.infoDiv]).remove();
+  } else {
+    Dygraph.warn('Tried to remove non-existent annotation.');
+  }
+};
+
+annotations.prototype.didDrawChart = function(e) {
+  var g = e.dygraph;
+
+  // Early out in the (common) case of zero annotations.
+  if (this.annotations_.length === 0) return;
+
+  this.updateAnnotationDivPositions();
+  this.attachAnnotationsToChart_();
+  this.updateAnnotationInfo();
+};
+
+annotations.prototype.pointClick = function(e) {
+  // Prevent any other behavior based on this click, e.g. creation of a hairline.
+  e.preventDefault();
+
+  var a = $.extend({}, this.defaultAnnotationProperties_, {
+    series: e.point.name,
+    xval: e.point.xval
+  });
+  this.annotations_.push(this.createAnnotation(a));
+
+  this.updateAnnotationDivPositions();
+  this.updateAnnotationInfo();
+  this.attachAnnotationsToChart_();
+
+  $(this).triggerHandler('annotationCreated', a);
+  $(this).triggerHandler('annotationsChanged', {});
+
+  // Annotations should begin life editable.
+  this.makeAnnotationEditable(a);
+};
+
+annotations.prototype.destroy = function() {
+  this.detachLabels();
+};
+
+
+// Public API
+
+/**
+ * This is a restricted view of this.annotations_ which doesn't expose
+ * implementation details like the line / info divs.
+ *
+ * @typedef {
+ *   xval:  number,      // x-value (i.e. millis or a raw number)
+ *   series: string,     // series name
+ * } PublicAnnotation
+ */
+
+/**
+ * @return {!Array.<!PublicAnnotation>} The current set of annotations, ordered
+ *     from back to front.
+ */
+annotations.prototype.get = function() {
+  var result = [];
+  for (var i = 0; i < this.annotations_.length; i++) {
+    result.push(this.createPublicAnnotation_(this.annotations_[i]));
+  }
+  return result;
+};
+
+/**
+ * Calling this will result in an annotationsChanged event being triggered, no
+ * matter whether it consists of additions, deletions, moves or no changes at
+ * all.
+ *
+ * @param {!Array.<!PublicAnnotation>} annotations The new set of annotations,
+ *     ordered from back to front.
+ */
+annotations.prototype.set = function(annotations) {
+  // Re-use divs from the old annotations array so far as we can.
+  // They're already correctly z-ordered.
+  var anyCreated = false;
+  for (var i = 0; i < annotations.length; i++) {
+    var a = annotations[i];
+
+    if (this.annotations_.length > i) {
+      // Only the divs need to be preserved.
+      var oldA = this.annotations_[i];
+      this.annotations_[i] = $.extend({
+        infoDiv: oldA.infoDiv,
+        lineDiv: oldA.lineDiv
+      }, a);
+    } else {
+      this.annotations_.push(this.createAnnotation(a));
+      anyCreated = true;
+    }
+  }
+
+  // If there are any remaining annotations, destroy them.
+  while (annotations.length < this.annotations_.length) {
+    this.removeAnnotation(this.annotations_[annotations.length]);
+  }
+
+  this.updateAnnotationDivPositions();
+  this.updateAnnotationInfo();
+  if (anyCreated) {
+    this.attachAnnotationsToChart_();
+  }
+
+  $(this).triggerHandler('annotationsChanged', {});
+};
+
+return annotations;
+
+})();
diff --git a/tests/hairlines.html b/tests/hairlines.html
new file mode 100644 (file)
index 0000000..42b7725
--- /dev/null
@@ -0,0 +1,342 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7; IE=EmulateIE9">
+    <title>Hairlines demo</title>
+    <!--[if IE]>
+    <script type="text/javascript" src="../excanvas.js"></script>
+    <![endif]-->
+    <script type="text/javascript" src="../dygraph-dev.js"></script>
+
+    <!-- Include the Javascript for the plug-in -->
+    <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
+    <script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.10.1/jquery-ui.min.js"></script>
+
+    <link rel='stylesheet' href='http://code.jquery.com/ui/1.10.1/themes/base/jquery-ui.css' />
+
+    <script type="text/javascript" src="../extras/hairlines.js"></script>
+    <script type="text/javascript" src="../extras/super-annotations.js"></script>
+
+    <style>
+      #demodiv {
+        position: absolute;
+        left: 10px;
+        right: 200px;
+        height: 400px;
+        display: inline-block;
+      }
+      #status {
+        position: absolute;
+        right: 10px;
+        width: 180px;
+        height: 400px;
+        display: inline-block;
+      }
+      #controls {
+        position: absolute;
+        left: 10px;
+        margin-top: 420px;
+      }
+
+      /* This style & the next show how you can customize the appearance of the
+         hairlines */
+      .hairline-info {
+        border: 1px solid black;
+        border-top-right-radius: 5px;
+        border-bottom-right-radius: 5px;
+
+        display: table;  /* shrink to fit */
+        min-width: 100px;
+
+        z-index: 10;  /* should appear on top of the chart */
+        padding: 3px;
+        background: white;
+        font-size: 14px;
+        cursor: move;
+      }
+
+      .dygraph-hairline {
+        /* border-right-style: dotted !important; */
+        cursor: move;
+      }
+
+      .dygraph-hairline.selected div {
+        left: 2px !important;
+        width: 2px !important;
+      }
+      .hairline-info.selected {
+        border: 2px solid black;
+        padding: 2px;
+      }
+
+      .annotation-info {
+        background: white;
+        border-width: 1px;
+        border-style: solid;
+        padding: 4px;
+        display: table;  /* shrink to fit */
+        box-shadow: 0 0 4px gray;
+        cursor: move;
+
+        min-width: 120px;  /* prevents squishing at the right edge of the chart */
+      }
+      .annotation-info.editable {
+        min-width: 180px;  /* prevents squishing at the right edge of the chart */
+      }
+
+      .dygraph-annotation-line {
+        box-shadow: 0 0 4px gray;
+      }
+    </style>
+  </head>
+  <body>
+    <h2>Hairlines Demo</h2>
+
+    <p>Click the chart to add a hairline. Drag the hairline to move it.</p>
+    <p>Click a point to add an editable annotation. Drag it to move it up/down.</p>
+
+    <!--
+    The "info box" for each hairline is based on this template.
+    Customize it as you wish. The .hairline-legend element will be populated
+    with data about the current points and the .hairline-kill-button element
+    will remove the hairline when clicked. Everything else will be untouched.
+    -->
+    <div id="hairline-template" class="hairline-info" style="display:none">
+      <button class='hairline-kill-button'>Kill</button>
+      <div class='hairline-legend'></div>
+    </div>
+    <div id="annotation-template" class="annotation-info" style="display:none">
+      <div>{{text}}</div>
+      <div>{{x}}, {{series}}: {{y}}</div>
+    </div>
+    <div id="annotation-editable-template" class="annotation-info" style="display:none">
+      <button class='annotation-kill-button'>Delete</button>
+      <button class='annotation-update'>Change</button>
+      <button class='annotation-cancel'>Cancel</button><br/>
+      <input dg-ann-field='text' type='text' size=30 value='{{text}}' />
+      <div>{{x}}, {{series}}: {{y}}</div>
+    </div>
+    <script type="text/javascript">
+    $(document).on('keyup', '.annotation-info input', function(e) {
+      var $annotationDiv = $(this).parent('.annotation-info');
+      if (e.keyCode == 13 || e.keyCode == 10) {  // enter
+        $annotationDiv.find('.annotation-update').click();
+      } else if (e.keyCode == 27) {  // escape
+        $annotationDiv.find('.annotation-cancel').click();
+      }
+    })
+    .on('dblclick', '.annotation-info', function(e) {
+      if (e.target.tagName == 'INPUT') return;
+      $(this).find('.annotation-cancel').click();
+    });
+    </script>
+
+    <div id="demodiv"></div>
+    <div id="status"></div>
+
+    <div id="controls">
+      <input type="checkbox" id="update" checked=true><label for="update"> Update</label>
+
+      <button id="add-button">Add a Hairline</button>
+      <button id="remove-button">Remove a Hairline</button>
+      <button id="reset-button">Reset Hairlines</button>
+      <br/>
+      Hairline mode:
+      <input type=radio name="hairline-mode" id="hairline-interpolated" checked=true>
+      <label for="hairline-interpolated"> Interpolated</label>
+      <input type=radio name="hairline-mode" id="hairline-closest">
+      <label for="hairline-closest"> Closest</label>
+
+      <p>Learn more about the <a href="https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit">Hairlines/Super-annotations plugins and their APIs</a>.</p>
+
+    </div>
+
+
+    <script type="text/javascript">
+      var last_t = 0;
+      var data = [];
+      var fn = function(t) {
+        return Math.sin(Math.PI/180 * t * 4);
+      };
+      for (; last_t < 200; last_t++) {
+        data.push([last_t, fn(last_t)]);
+      }
+
+      hairlines = new Dygraph.Plugins.Hairlines({
+        divFiller: function(div, data) {
+          // This behavior is identical to what you'd get if you didn't set
+          // this option. It illustrates how to write a 'divFiller'.
+          var html = Dygraph.Plugins.Legend.generateLegendHTML(
+              data.dygraph, data.hairline.xval, data.points, 10);
+          $('.hairline-legend', div).html(html);
+          $(div).data({xval: data.hairline.xval});  // see .hover() below.
+        }
+      });
+      annotations = new Dygraph.Plugins.SuperAnnotations({
+        defaultAnnotationProperties: {
+          'text': 'Annotation Description'
+        }
+      });
+      g = new Dygraph(
+              document.getElementById("demodiv"),
+              data,
+              {
+                labelsDiv: document.getElementById('status'),
+                labelsSeparateLines: true,
+                legend: 'always',
+                labels: [ 'Time', 'Value' ],
+
+                axes: {
+                  x: {
+                    valueFormatter: function(val) {
+                      return val.toFixed(2);
+                    }
+                  },
+                  y: {
+                    pixelsPerLabel: 50
+                  }
+                },
+
+                // Set the plug-ins in the options.
+                plugins : [
+                  annotations,
+                  hairlines
+                ]
+              }
+          );
+
+      var shouldUpdate = true;
+      var update = function() {
+        if (!shouldUpdate) return;
+        data.push([last_t, fn(last_t)]);
+        last_t++;
+        data.splice(0, 1);
+        g.updateOptions({file: data});
+      };
+      window.setInterval(update, 1000);
+
+      // Control handlers
+      $('#update').on('change', function() {
+        shouldUpdate = $(this).is(':checked');
+      });
+
+      $('#add-button').on('click', function(e) {
+        var h = hairlines.get();
+        h.push({xval: 137});
+        hairlines.set(h);
+      });
+      $('#remove-button').on('click', function(e) {
+        var h = hairlines.get();
+        if (h.length > 0) {
+          var idx = Math.floor(h.length / 2);
+          h.splice(idx, 1);
+        }
+        hairlines.set(h);
+      });
+      $('#reset-button').on('click', function(e) {
+        setDefaultState();
+      });
+      function setHairlineModeRadio() {
+        var hs = hairlines.get();
+        if (hs.length) {
+          var interpolated = hs[0].interpolated;
+          $('#hairline-interpolated').prop('checked', interpolated);
+          $('#hairline-closest').prop('checked', !interpolated);
+        }
+      }
+      $('[name=hairline-mode]').change(function() {
+        var interpolated = $('#hairline-interpolated').is(':checked');
+        var hs = hairlines.get();
+        for (var i = 0; i < hs.length; i++) {
+          hs[i].interpolated = interpolated;
+        }
+        hairlines.set(hs);
+      });
+
+      // Persistence
+      function loadFromStorage() {
+        hairlines.set(JSON.parse(localStorage.getItem('hairlines')));
+        annotations.set(JSON.parse(localStorage.getItem('annotations')));
+        setHairlineModeRadio();
+      }
+      $(hairlines).on('hairlinesChanged', function(e) {
+        localStorage.setItem('hairlines', JSON.stringify(hairlines.get()));
+        setHairlineModeRadio();
+      });
+      $(annotations).on('annotationsChanged', function(e) {
+        localStorage.setItem('annotations', JSON.stringify(annotations.get()));
+      });
+      function setDefaultState() {
+        // triggers 'hairlinesChanged' and 'annotationsChanged' events, above.
+        hairlines.set([{xval: 55}]);
+        annotations.set([{
+          xval: 67,
+          series: 'Value',
+          text: 'Bottom'
+        },
+        {
+          xval: 137,
+          series: 'Value',
+          text: 'Fast Change'
+        }]);
+      }
+
+      if (!localStorage.getItem('hairlines') ||
+          !localStorage.getItem('annotations')) {
+        setDefaultState();
+      } else {
+        loadFromStorage();
+      }
+
+      // Set focus on text box when you edit an annotation.
+      $(annotations).on('beganEditAnnotation', function(e, a) {
+        $('input[type=text]', a.infoDiv).focus();
+      });
+
+      // Select/Deselect hairlines on click.
+      $(document).on('click', '.hairline-info', function() {
+        console.log('click');
+        var xval = $(this).data('xval');
+        var hs = hairlines.get();
+        for (var i = 0; i < hs.length; i++) {
+          if (hs[i].xval == xval) {
+            hs[i].selected = !hs[i].selected;
+          }
+        }
+        hairlines.set(hs);
+      });
+
+      // Demonstration of how to use various other event listeners
+      $(hairlines).on({
+        'hairlineMoved': function(e, data) {
+          // console.log('hairline moved from', data.oldXVal, ' to ', data.newXVal);
+        },
+        'hairlineCreated': function(e, data) {
+          console.log('hairline created at ', data.xval);
+        },
+        'hairlineDeleted': function(e, data) {
+          console.log('hairline deleted at ', data.xval);
+        }
+      });
+      $(annotations).on({
+        'annotationCreated': function(e, data) {
+          console.log('annotation created at ', data.series, data.xval);
+        },
+        'annotationMoved': function(e, data) {
+          console.log('annotation moved from ', data.oldYFrac, ' to ', data.newYFrac);
+        },
+        'annotationDeleted': function(e, data) {
+          console.log('annotation deleted at ', data.series, data.xval);
+        },
+        'annotationEdited': function(e, data) {
+          console.log('edited annotation at ', data.series, data.xval);
+        },
+        'cancelEditAnnotation': function(e, data) {
+          console.log('edit canceled on annotation at ', data.series, data.xval);
+        }
+      });
+
+      // TODO(danvk): demonstrate other annotations API methods.
+    </script>
+</body>
+</html>