X-Git-Url: https://adrianiainlam.tk/git/?a=blobdiff_plain;f=src%2Fextras%2Fhairlines.js;fp=src%2Fextras%2Fhairlines.js;h=904e432e8ac9bad9a069a1995c190f819abbd88f;hb=3123ca57f71d145bb5bcc4a2f754d3dff3225346;hp=0000000000000000000000000000000000000000;hpb=26ee953643ccd2d32e38e6b60b20e6a01c1dc9ba;p=dygraphs.git diff --git a/src/extras/hairlines.js b/src/extras/hairlines.js new file mode 100644 index 0000000..904e432 --- /dev/null +++ b/src/extras/hairlines.js @@ -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.} */ + 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 = $('
').css({ + 'width': '6px', + 'margin-left': '-3px', + 'position': 'absolute', + 'z-index': '10' + }) + .addClass('dygraph-hairline'); + + var $lineDiv = $('
').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.} 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.} 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; + +})();