dygraphs
Version:
dygraphs is a fast, flexible open source JavaScript charting library.
473 lines (408 loc) • 13.9 kB
JavaScript
/**
* @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 */ {});
})();