hairline kill working
authorDan Vanderkam <danvdk@gmail.com>
Sun, 7 Apr 2013 19:02:01 +0000 (15:02 -0400)
committerDan Vanderkam <danvdk@gmail.com>
Sun, 7 Apr 2013 19:02:01 +0000 (15:02 -0400)
dygraph-interaction-model.js
extras/hairlines.js
plugins/legend.js
tests/hairlines.html

index 2af345c..4645aa4 100644 (file)
@@ -271,6 +271,7 @@ Dygraph.Interaction.moveZoom = function(event, g, context) {
 };
 
 /**
+ * TODO(danvk): move this logic into dygraph.js
  * @param {Dygraph} g
  * @param {Event} event
  * @param {Object} context
@@ -310,9 +311,17 @@ Dygraph.Interaction.treatMouseOpAsClick = function(g, event, context) {
     pointClickCallback(event, selectedPoint);
   }
 
-  // TODO(danvk): pass along more info about the points, e.g. 'x'
-  if (clickCallback) {
-    clickCallback(event, g.lastx_, g.selPoints_);
+  var e = {
+    xval: g.lastx_,  // closest point by x value
+    pts: g.selPoints_,
+    canvasx: context.dragEndX,
+    canvasy: context.dragEndY
+  };
+  if (!g.cascadeEvents_('click', e)) {
+    if (clickCallback) {
+      // TODO(danvk): pass along more info about the points, e.g. 'x'
+      clickCallback(event, g.lastx_, g.selPoints_);
+    }
   }
 };
 
@@ -621,6 +630,16 @@ Dygraph.Interaction.defaultModel = {
       context.cancelNextDblclick = false;
       return;
     }
+
+    // Give plugins a chance to grab this event.
+    var e = {
+      canvasx: context.dragEndX,
+      canvasy: context.dragEndY
+    };
+    if (g.cascadeEvents_('dblclick', e)) {
+      return;
+    }
+
     if (event.altKey || event.shiftKey) {
       return;
     }
index db82d6e..c653daa 100644 (file)
@@ -2,28 +2,23 @@
  * @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 */
 
+var allHairlines = [];
+
 Dygraph.Plugins.Hairlines = (function() {
 
 "use strict";
 
 /**
-Current bits of jankiness:
-- Uses dygraph.layout_ to get the parsed hairlines.
-- Uses dygraph.plotter_.area
-
-It would be nice if the plugin didn't require so much special support inside
-the core dygraphs classes, but hairlines involve quite a bit of parsing and
-layout.
-
-TODO(danvk): cache DOM elements.
-
-*/
-
-/**
+ * xFraction is the position of the hairline on the chart, where 0.0=left edge
+ * of the chart area and 1.0=right edge. Unlike 'canvas' coordinates, it does
+ * not include the y-axis labels.
+ *
  * @typedef {
  *   xFraction: number,   // invariant across resize
  *   interpolated: bool,  // alternative is to snap to closest
@@ -32,6 +27,10 @@ TODO(danvk): cache DOM elements.
  * } 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() {
   /* @type {!Array.<!Hairline>} */
   this.hairlines_ = [];
@@ -40,6 +39,8 @@ var hairlines = function() {
   this.lastWidth_ = -1;
   this.lastHeight = -1;
   this.dygraph_ = null;
+
+  this.addTimer_ = null;
 };
 
 hairlines.prototype.toString = function() {
@@ -51,7 +52,9 @@ hairlines.prototype.activate = function(g) {
   this.hairlines_ = [this.createHairline(0.55)];
 
   return {
-    didDrawChart: this.didDrawChart
+    didDrawChart: this.didDrawChart,
+    click: this.click,
+    dblclick: this.dblclick
   };
 };
 
@@ -72,29 +75,24 @@ hairlines.prototype.hairlineWasDragged = function(h, event, ui) {
   this.updateHairlineInfo();
 };
 
+// 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(xFraction) {
   var h;
   var self = this;
 
   var $lineDiv = $('<div/>').css({
-    'border-right': '1px solid black',
-    'width': '0px',
-    'position': 'absolute',
-    'z-index': '10'
-  })
-    .addClass('dygraph-hairline')
-    .appendTo(this.dygraph_.graphDiv);
-
-  var $infoDiv = $('<div/>').css({
-    'border': '1px solid black',
-    'display': 'table',  // shrink to fit
-    'z-index': '10',
-    'padding': '3px',
-    'background': 'white',
-    'position': 'absolute'
-  })
-    .addClass('dygraph-hairline-info')
-    .text('Info')
+      'border-right': '1px solid black',
+      'width': '0px',
+      'position': 'absolute',
+      'z-index': '10'
+    })
+    .addClass('dygraph-hairline');
+
+  var $infoDiv = $('#hairline-template').clone().removeAttr('id').css({
+      'position': 'absolute'
+    })
+    .show()
     .draggable({
       'axis': 'x',
       'containment': 'parent',
@@ -102,8 +100,7 @@ hairlines.prototype.createHairline = function(xFraction) {
         self.hairlineWasDragged(h, event, ui);
       }
       // TODO(danvk): set cursor here
-    })
-    .appendTo(this.dygraph_.graphDiv);
+    });
 
   h = {
     xFraction: xFraction,
@@ -112,6 +109,11 @@ hairlines.prototype.createHairline = function(xFraction) {
     infoDiv: $infoDiv.get(0)
   };
 
+  var that = this;
+  $infoDiv.on('click', '.hairline-kill-button', function() {
+    that.removeHairline(h);
+  });
+
   return h;
 };
 
@@ -134,26 +136,69 @@ hairlines.prototype.updateHairlineDivPositions = function() {
 
 // Fills out the info div based on current coordinates.
 hairlines.prototype.updateHairlineInfo = function() {
+  var mode = 'closest';
+
   var g = this.dygraph_;
   var xRange = g.xAxisRange();
   $.each(this.hairlines_, function(idx, h) {
     var xValue = h.xFraction * (xRange[1] - xRange[0]) + xRange[0];
 
-    // TODO(danvk): find appropriate y-values and format them.
+    var row = null;
+    if (mode == 'closest') {
+      // TODO(danvk): make this dygraphs method public
+      row = g.findClosestRow(g.toDomXCoord(xValue));
+    } else if (mode == 'interpolate') {
+      // ...
+    }
+
+    // To use generateLegendHTML, we have to synthesize an array of selected
+    // points.
+    var selPoints = [];
+    var labels = g.getLabels();
+    for (var i = 1; i < g.numColumns(); i++) {
+      selPoints.push({
+        canvasx: 1,
+        canvasy: 1,
+        xval: xValue,
+        yval: g.getValue(row, i),
+        name: labels[i]
+      });
+    }
+
+    var html = Dygraph.Plugins.Legend.generateLegendHTML(g, xValue, selPoints, 10);
+    $('.hairline-legend', h.infoDiv).html(html);
+  });
+};
 
-    var xOptView = g.optionsViewForAxis_('x');
-    var xvf = xOptView('valueFormatter');
-    var html = xvf(xValue, xOptView, xValue, g);
-    $(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;
 
+  allHairlines = this.hairlines_;
+
   // Early out in the (common) case of zero hairlines.
   if (this.hairlines_.length === 0) return;
 
+  // TODO(danvk): recreate the hairline divs when the chart resizes.
   var containerDiv = e.canvas.parentNode;
   var width = containerDiv.offsetWidth;
   var height = containerDiv.offsetHeight;
@@ -161,11 +206,39 @@ hairlines.prototype.didDrawChart = function(e) {
     this.lastWidth_ = width;
     this.lastHeight_ = height;
     this.updateHairlineDivPositions();
+    this.attachHairlinesToChart_();
   }
 
   this.updateHairlineInfo();
 };
 
+hairlines.prototype.click = function(e) {
+  if (this.addTimer_) {
+    // Another click is in progress; ignore this one.
+    return;
+  }
+
+  var area = e.dygraph.getArea();
+  var xFraction = (e.canvasx - area.x) / area.w;
+
+  var that = this;
+  this.addTimer_ = setTimeout(function() {
+    that.addTimer_ = null;
+    that.hairlines_.push(that.createHairline(xFraction));
+
+    that.updateHairlineDivPositions();
+    that.updateHairlineInfo();
+    that.attachHairlinesToChart_();
+  }, CLICK_DELAY_MS);
+};
+
+hairlines.prototype.dblclick = function(e) {
+  if (this.addTimer_) {
+    clearTimeout(this.addTimer_);
+    this.addTimer_ = null;
+  }
+};
+
 hairlines.prototype.destroy = function() {
   this.detachLabels();
 };
index 7406f82..81862c8 100644 (file)
@@ -38,7 +38,7 @@ legend.prototype.toString = function() {
 };
 
 // (defined below)
-var generateLegendHTML, generateLegendDashHTML;
+var generateLegendDashHTML;
 
 /**
  * This is called during the dygraph constructor, after options have been set
@@ -124,7 +124,7 @@ legend.prototype.select = function(e) {
   var xValue = e.selectedX;
   var points = e.selectedPoints;
 
-  var html = generateLegendHTML(e.dygraph, xValue, points, this.one_em_width_);
+  var html = legend.generateLegendHTML(e.dygraph, xValue, points, this.one_em_width_);
   this.legend_div_.innerHTML = html;
 };
 
@@ -133,7 +133,7 @@ legend.prototype.deselect = function(e) {
   var oneEmWidth = calculateEmWidthInDiv(this.legend_div_);
   this.one_em_width_ = oneEmWidth;
 
-  var html = generateLegendHTML(e.dygraph, undefined, undefined, oneEmWidth);
+  var html = legend.generateLegendHTML(e.dygraph, undefined, undefined, oneEmWidth);
   this.legend_div_.innerHTML = html;
 };
 
@@ -183,7 +183,7 @@ legend.prototype.destroy = function() {
  * relevant when displaying a legend with no selection (i.e. {legend:
  * 'always'}) and with dashed lines.
  */
-generateLegendHTML = function(g, x, sel_points, oneEmWidth) {
+legend.generateLegendHTML = function(g, x, sel_points, oneEmWidth) {
   // TODO(danvk): deprecate this option in place of {legend: 'never'}
   if (g.getOption('showLabelsOnHighlight') !== true) return '';
 
index ae42f80..0fbe795 100644 (file)
         height: 400px;
         display: inline-block;
       }
-      /*
+      #controls {
+        position: absolute;
+        left: 10px;
+        top: 490px;
+      }
+
+      .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;
+        /* border-right-style: dotted !important; */
       }
-      */
     </style>
   </head>
   <body>
     <h2>Hairlines Demo</h2>
 
+    <!--
+    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="demodiv"></div>
     <div id="status"></div>
 
+    <div id="controls">
+      <input type="checkbox" id="update" checked=true><label for="update"> Update</label>
+    </div>
+
     <script type="text/javascript">
       var last_t = 0;
       var data = [];
                 labelsSeparateLines: true,
                 legend: 'always',
                 labels: [ 'Time', 'Value' ],
-                title: 'Interesting Shapes',
+                title: 'Hairlines Demo',
+
+                axes: {
+                  x: {
+                    valueFormatter: function(val) {
+                      return val.toFixed(2);
+                    }
+                  },
+                  y: {
+                    pixelsPerLabel: 50
+                  }
+                },
 
                 // Set the plug-ins in the options.
                 plugins : [
               }
           );
 
+      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);
+
+      $('#update').on('change', function() {
+        shouldUpdate = $(this).is(':checked');
+      });
     </script>
 </body>
 </html>