super annotations mostly working; still need API and live updates
authorDan Vanderkam <danvdk@gmail.com>
Mon, 20 May 2013 01:24:48 +0000 (21:24 -0400)
committerDan Vanderkam <danvdk@gmail.com>
Mon, 20 May 2013 01:24:48 +0000 (21:24 -0400)
dygraph-interaction-model.js
extras/hairlines.js
extras/super-annotations.js [new file with mode: 0644]
tests/hairlines.html

index 4645aa4..753e023 100644 (file)
@@ -282,33 +282,43 @@ Dygraph.Interaction.treatMouseOpAsClick = function(g, event, context) {
 
   var selectedPoint = null;
 
-  // Find out if the click occurs on a point. This only matters if there's a
-  // pointClickCallback.
-  if (pointClickCallback) {
-    var closestIdx = -1;
-    var closestDistance = Number.MAX_VALUE;
-
-    // check if the click was on a particular point.
-    for (var i = 0; i < g.selPoints_.length; i++) {
-      var p = g.selPoints_[i];
-      var distance = Math.pow(p.canvasx - context.dragEndX, 2) +
-                     Math.pow(p.canvasy - context.dragEndY, 2);
-      if (!isNaN(distance) &&
-          (closestIdx == -1 || distance < closestDistance)) {
-        closestDistance = distance;
-        closestIdx = i;
-      }
+  // Find out if the click occurs on a point.
+  var closestIdx = -1;
+  var closestDistance = Number.MAX_VALUE;
+
+  // check if the click was on a particular point.
+  for (var i = 0; i < g.selPoints_.length; i++) {
+    var p = g.selPoints_[i];
+    var distance = Math.pow(p.canvasx - context.dragEndX, 2) +
+                   Math.pow(p.canvasy - context.dragEndY, 2);
+    if (!isNaN(distance) &&
+        (closestIdx == -1 || distance < closestDistance)) {
+      closestDistance = distance;
+      closestIdx = i;
     }
+  }
 
-    // Allow any click within two pixels of the dot.
-    var radius = g.attr_('highlightCircleSize') + 2;
-    if (closestDistance <= radius * radius) {
-      selectedPoint = g.selPoints_[closestIdx];
-    }
+  // Allow any click within two pixels of the dot.
+  var radius = g.attr_('highlightCircleSize') + 2;
+  if (closestDistance <= radius * radius) {
+    selectedPoint = g.selPoints_[closestIdx];
   }
 
   if (selectedPoint) {
-    pointClickCallback(event, selectedPoint);
+    var e = {
+      cancelable: true,
+      point: selectedPoint,
+      canvasx: context.dragEndX,
+      canvasy: context.dragEndY
+    };
+    var defaultPrevented = g.cascadeEvents_('pointClick', e);
+    if (defaultPrevented) {
+      // Note: this also prevents click / clickCallback from firing.
+      return;
+    }
+    if (pointClickCallback) {
+      pointClickCallback(event, selectedPoint);
+    }
   }
 
   var e = {
index f8a9d42..3f0ac9a 100644 (file)
@@ -8,8 +8,6 @@
 
 /*global Dygraph:false */
 
-var allHairlines = [];
-
 Dygraph.Plugins.Hairlines = (function() {
 
 "use strict";
@@ -232,8 +230,6 @@ hairlines.prototype.removeHairline = function(h) {
 hairlines.prototype.didDrawChart = function(e) {
   var g = e.dygraph;
 
-  allHairlines = this.hairlines_;
-
   // Early out in the (common) case of zero hairlines.
   if (this.hairlines_.length === 0) return;
 
diff --git a/extras/super-annotations.js b/extras/super-annotations.js
new file mode 100644 (file)
index 0000000..d009576
--- /dev/null
@@ -0,0 +1,405 @@
+/**
+ * @license
+ * Copyright 2013 Dan Vanderkam (danvdk@gmail.com)
+ * MIT-licensed (http://opensource.org/licenses/MIT)
+ *
+ * Note: This plugin requires jQuery and jQuery UI Draggable.
+ */
+
+/*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
+ *   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 oldXVal = a.xval;
+
+  // TODO(danvk): find closest point
+  // - area.x  ?
+  var row = g.findClosestRow(ui.position.left);
+  a.xval = g.getValue(row, 0);
+  g.setSelection(row, a.series);
+
+  this.moveAnnotationToTop(a);
+  this.updateAnnotationDivPositions();
+  this.updateAnnotationInfo();
+  // $(this).triggerHandler('hairlineMoved', {
+  //   // TODO(danvk): fill in
+  // });
+  // $(this).triggerHandler('annotationsChanged', {});
+};
+
+// 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(series, xval) {
+  var a;
+  var self = this;
+
+  var color = this.getColorForSeries_(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
+  });
+
+  var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({
+      'position': 'absolute',
+      'border-color': color
+    })
+    .show();
+
+  a = {
+    xval: xval,
+    series: series,
+    lineDiv: $lineDiv.get(0),
+    infoDiv: $infoDiv.get(0)
+  };
+  $.extend(a, this.defaultAnnotationProperties_);
+
+  var that = this;
+
+  $infoDiv.draggable({
+    'start': function(event, ui) {
+      $(this).css({'bottom': ''});
+    },
+    'drag': function(event, ui) {
+      self.annotationWasDragged(a, event, ui);
+    },
+    'stop': function(event, ui) {
+      $(this).css({'top': ''});
+      self.updateAnnotationDivPositions();
+    }
+  });
+
+  // TODO(danvk): use 'on' instead of 
+  $infoDiv.delegate('.annotation-kill-button', 'click', function() {
+    that.removeAnnotation(a);
+    $(that).triggerHandler('annotationDeleted', a);
+    $(that).triggerHandler('annotationsChanged', {});
+  });
+
+  $infoDiv.dblclick(function() {
+    if (a.editable == true) return;
+    a.editable = true;
+    self.updateAnnotationInfo();
+  });
+  $infoDiv.delegate('.annotation-update', 'click', function() {
+    self.extractUpdatedProperties_($infoDiv.get(0), a);
+    a.editable = false;
+    self.updateAnnotationInfo();
+  });
+  $infoDiv.delegate('.annotation-cancel', 'click', function() {
+    a.editable = false;
+    self.updateAnnotationInfo();
+  });
+
+  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 div = this.dygraph_.graphDiv;
+  var box = [layout.x + Dygraph.findPosX(div),
+             layout.y + Dygraph.findPosY(div)];
+  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) {
+    // TODO(danvk): cache this information for each annotation
+    var row_col = that.findPointIndex_(a.series, a.xval);
+    var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1]));
+    var x = xy[0], y = xy[1];
+
+    var lineHeight = 6;
+
+    $(a.lineDiv).css({
+      'left': x + 'px',
+      'top': (y - lineHeight) + 'px',
+      'height': lineHeight + 'px'
+    });
+    $(a.infoDiv).css({
+      'left': x + 'px',
+      'bottom': (div.offsetHeight - (y - lineHeight)) + 'px'
+    })  //.draggable("option", "containment", box);
+  });
+};
+
+// 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);
+  var editableTemplateDiv = $('#annotation-editable-template').get(0);
+  $.each(this.annotations_, function(idx, a) {
+    var div = a.editable ? editableTemplateDiv : templateDiv;
+    a.infoDiv.innerHTML = that.getTemplateHTML(div, a);
+  });
+};
+
+// 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);
+  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 = $.extend({}, 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) {
+    $([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();
+
+  this.annotations_.push(this.createAnnotation(e.point.name, e.point.xval));
+
+  this.updateAnnotationDivPositions();
+  this.updateAnnotationInfo();
+  this.attachAnnotationsToChart_();
+
+  $(this).triggerHandler('hairlineCreated', {
+    // TODO
+    // xFraction: xFraction
+  });
+  $(this).triggerHandler('annotationsChanged', {});
+};
+
+annotations.prototype.destroy = function() {
+  this.detachLabels();
+};
+
+
+// Public API
+
+/**
+ * This is a restricted view of this.annotations_ which doesn't expose
+ * implementation details like the handle divs.
+ *
+ * @typedef {
+ *   xFraction: number,   // invariant across resize
+ *   interpolated: bool   // alternative is to snap to closest
+ * } 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++) {
+    var h = this.annotations_[i];
+    result.push({
+      xFraction: h.xFraction,
+      interpolated: h.interpolated
+    });
+  }
+  return result;
+};
+
+/**
+ * Calling this will result in a 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 h = annotations[i];
+
+    if (this.annotations_.length > i) {
+      this.annotations_[i].xFraction = h.xFraction;
+      this.annotations_[i].interpolated = h.interpolated;
+    } else {
+      // TODO(danvk): pass in |interpolated| value.
+      this.annotations_.push(this.createAnnotation(h.xFraction));
+      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;
+
+})();
index 7cf9824..199cf45 100644 (file)
@@ -9,11 +9,18 @@
     <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>
+    -->
+
+    <script src="jquery-1.6.2.min.js"></script>
+    <script src="jquery-ui-1.8.14.custom.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 {
         /* border-right-style: dotted !important; */
         cursor: move;
       }
+
+      .annotation-info {
+        background: white;
+        border-width: 1px;
+        border-style: solid;
+        padding: 4px;
+        display: table;  /* shrink to fit */
+      }
     </style>
   </head>
   <body>
       <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).delegate('.annotation-info input', 'keyup', 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();
+      }
+    })
+    .delegate('.annotation-info', 'dblclick', function(e) {
+      if (e.target.tagName == 'INPUT') return;
+      $(this).find('.annotation-cancel').click();
+    });
+    </script>
 
     <div id="demodiv"></div>
     <div id="status"></div>
       }
 
       hairlines = new Dygraph.Plugins.Hairlines();
+      annotations = new Dygraph.Plugins.SuperAnnotations({
+        defaultAnnotationProperties: {
+          'text': 'Annotation Description'
+        }
+      });
       g = new Dygraph(
               document.getElementById("demodiv"),
               data,
 
                 // Set the plug-ins in the options.
                 plugins : [
-                  hairlines
+                  hairlines,
+                  annotations
                 ]
               }
           );
 
-      var shouldUpdate = true;
+      var shouldUpdate = false;  // true;
       var update = function() {
         if (!shouldUpdate) return;
         data.push([last_t, fn(last_t)]);
         console.log('hairline deleted at ', data.xFraction);
       });
     </script>
+
+    <!--
+
+    Some ways to do annotations:
+    1. Completely independent of existing annotations plugin.
+    2. As a subclass of existing annotations plugin.
+    3. As a modification to the existing annotations plugin.
+
+    1. Most flexibility, most duplication of code.
+       Miss out on features like support for stacked charts.
+       Least controversial.
+       API is least constrained.
+
+    2. Would require a few modifications to the existing annotations plugin
+       (e.g. a method to create the DOM element).
+       Would also require a way for a plugin to disable another plugin.
+
+    3.
+
+    -->
 </body>
 </html>