--- /dev/null
+/**
+ * @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;
+
+})();