UNPKG

dygraphs

Version:

dygraphs is a fast, flexible open source JavaScript charting library.

473 lines (408 loc) 13.9 kB
/** * @license * Copyright 2013 Dan Vanderkam (danvdk@gmail.com) * MIT-licenced: https://opensource.org/licenses/MIT * * Note: This plugin requires jQuery and jQuery UI Draggable. * * See high-level documentation at ../../docs/hairlines-annotations.pdf */ /* loader wrapper to allow browser use and ES6 imports */ (function _extras_hairlines_wrapper() { 'use strict'; var Dygraph; if (window.Dygraph) { Dygraph = window.Dygraph; } else if (typeof(module) !== 'undefined') { Dygraph = require('../dygraph'); if (typeof(Dygraph.NAME) === 'undefined' && typeof(Dygraph.default) !== 'undefined') Dygraph = Dygraph.default; } /* end of loader wrapper header */ Dygraph.Plugins.Hairlines = (function _extras_hairlines_closure() { "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 hairlines(opt_options) { /** @private {!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 toString() { return "Hairlines Plugin"; }; hairlines.prototype.activate = function activate(g) { this.dygraph_ = g; this.hairlines_ = []; return { didDrawChart: this.didDrawChart, click: this.click, dblclick: this.dblclick, dataDidUpdate: this.dataDidUpdate }; }; hairlines.prototype.detachLabels = function detachLabels() { 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 hairlineWasDragged(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 createHairline(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 dragWrapper_(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 clickEvent_(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 clickHandler_() { that.moveHairlineToTop(h); }); return h; }; // Moves a hairline's divs to the top of the z-ordering. hairlines.prototype.moveHairlineToTop = function moveHairlineToTop(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 updateHairlineDivPositions() { 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 iterateHairlines_(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 updateHairlineStyles() { $.each(this.hairlines_, function iterateHairlines_(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 findPrevNextRows(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 updateHairlineInfo() { var mode = 'closest'; var g = this.dygraph_; var xRange = g.xAxisRange(); var that = this; $.each(this.hairlines_, function iterateHairlines_(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 attachHairlinesToChart_() { var div = this.dygraph_.graphDiv; $.each(this.hairlines_, function iterateHairlines_(idx, h) { $([h.lineDiv, h.infoDiv]).appendTo(div); }); }; // Deletes a hairline and removes it from the chart. hairlines.prototype.removeHairline = function removeHairline(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 didDrawChart(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 dataDidUpdate(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 iterateHairlines_(idx, h) { if (h.hasOwnProperty('domX')) { h.xval = g.toDataXCoord(h.domX); } }); }; hairlines.prototype.click = function click(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 click_tmo_() { 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 dblclick(e) { if (this.addTimer_) { clearTimeout(this.addTimer_); this.addTimer_ = null; } }; hairlines.prototype.destroy = function destroy() { 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 createPublicHairline_(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 get() { 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 set(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; })(); /* loader wrapper */ Dygraph._require.add('dygraphs/src/extras/hairlines.js', /* exports */ {}); })();