assets.lib.dygraphs.extras.super-annotations.js Maven / Gradle / Ivy
The newest version!
/**
* @license
* Copyright 2013 Dan Vanderkam ([email protected])
* 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.SuperAnnotations = (function() {
"use strict";
/**
* These are just the basic requirements -- annotations can have whatever other
* properties the code that displays them wants them to have.
*
* @typedef {
* xval: number, // x-value (i.e. millis or a raw number)
* series: string, // series name
* yFrac: ?number, // y-positioning. Default is a few px above the point.
* lineDiv: !Element // vertical div connecting point to info div.
* infoDiv: !Element // div containing info about the annotation.
* } Annotation
*/
var annotations = function(opt_options) {
/* @type {!Array.} */
this.annotations_ = [];
// Used to detect resizes (which require the divs to be repositioned).
this.lastWidth_ = -1;
this.lastHeight = -1;
this.dygraph_ = null;
opt_options = opt_options || {};
this.defaultAnnotationProperties_ = $.extend({
'text': 'Description'
}, opt_options['defaultAnnotationProperties']);
};
annotations.prototype.toString = function() {
return "SuperAnnotations Plugin";
};
annotations.prototype.activate = function(g) {
this.dygraph_ = g;
this.annotations_ = [];
return {
didDrawChart: this.didDrawChart,
pointClick: this.pointClick // TODO(danvk): implement in dygraphs
};
};
annotations.prototype.detachLabels = function() {
for (var i = 0; i < this.annotations_.length; i++) {
var a = this.annotations_[i];
$(a.lineDiv).remove();
$(a.infoDiv).remove();
this.annotations_[i] = null;
}
this.annotations_ = [];
};
annotations.prototype.annotationWasDragged = function(a, event, ui) {
var g = this.dygraph_;
var area = g.getArea();
var oldYFrac = a.yFrac;
var infoDiv = a.infoDiv;
var newYFrac = ((infoDiv.offsetTop + infoDiv.offsetHeight) - area.y) / area.h;
if (newYFrac == oldYFrac) return;
a.yFrac = newYFrac;
this.moveAnnotationToTop(a);
this.updateAnnotationDivPositions();
this.updateAnnotationInfo();
$(this).triggerHandler('annotationMoved', {
annotation: a,
oldYFrac: oldYFrac,
newYFrac: a.yFrac
});
$(this).triggerHandler('annotationsChanged', {});
};
annotations.prototype.makeAnnotationEditable = function(a) {
if (a.editable == true) return;
this.moveAnnotationToTop(a);
// Note: we have to fill out the HTML ourselves because
// updateAnnotationInfo() won't touch editable annotations.
a.editable = true;
var editableTemplateDiv = $('#annotation-editable-template').get(0);
a.infoDiv.innerHTML = this.getTemplateHTML(editableTemplateDiv, a);
$(a.infoDiv).toggleClass('editable', !!a.editable);
$(this).triggerHandler('beganEditAnnotation', a);
};
// This creates the hairline object and returns it.
// It does not position it and does not attach it to the chart.
annotations.prototype.createAnnotation = function(a) {
var self = this;
var color = this.getColorForSeries_(a.series);
var $lineDiv = $('').css({
'width': '1px',
'left': '3px',
'background': 'black',
'height': '100%',
'position': 'absolute',
// TODO(danvk): use border-color here for consistency?
'background-color': color,
'z-index': 10
}).addClass('dygraph-annotation-line');
var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({
'position': 'absolute',
'border-color': color,
'z-index': 10
})
.show();
$.extend(a, {
lineDiv: $lineDiv.get(0),
infoDiv: $infoDiv.get(0)
});
var that = this;
$infoDiv.draggable({
'start': function(event, ui) {
$(this).css({'bottom': ''});
a.isDragging = true;
},
'drag': function(event, ui) {
self.annotationWasDragged(a, event, ui);
},
'stop': function(event, ui) {
$(this).css({'top': ''});
a.isDragging = false;
self.updateAnnotationDivPositions();
},
'axis': 'y',
'containment': 'parent'
});
// TODO(danvk): use 'on' instead of delegate/dblclick
$infoDiv.on('click', '.annotation-kill-button', function() {
that.removeAnnotation(a);
$(that).triggerHandler('annotationDeleted', a);
$(that).triggerHandler('annotationsChanged', {});
});
$infoDiv.on('dblclick', function() {
that.makeAnnotationEditable(a);
});
$infoDiv.on('click', '.annotation-update', function() {
self.extractUpdatedProperties_($infoDiv.get(0), a);
a.editable = false;
self.updateAnnotationInfo();
$(that).triggerHandler('annotationEdited', a);
$(that).triggerHandler('annotationsChanged', {});
});
$infoDiv.on('click', '.annotation-cancel', function() {
a.editable = false;
self.updateAnnotationInfo();
$(that).triggerHandler('cancelEditAnnotation', a);
});
return a;
};
// Find the index of a point in a series.
// Returns a 2-element array, [row, col], which can be used with
// dygraph.getValue() to get the value at this point.
// Returns null if there's no match.
annotations.prototype.findPointIndex_ = function(series, xval) {
var col = this.dygraph_.getLabels().indexOf(series);
if (col == -1) return null;
var lowIdx = 0, highIdx = this.dygraph_.numRows() - 1;
while (lowIdx <= highIdx) {
var idx = Math.floor((lowIdx + highIdx) / 2);
var xAtIdx = this.dygraph_.getValue(idx, 0);
if (xAtIdx == xval) {
return [idx, col];
} else if (xAtIdx < xval) {
lowIdx = idx + 1;
} else {
highIdx = idx - 1;
}
}
return null;
};
annotations.prototype.getColorForSeries_ = function(series) {
var colors = this.dygraph_.getColors();
var col = this.dygraph_.getLabels().indexOf(series);
if (col == -1) return null;
return colors[(col - 1) % colors.length];
};
// Moves a hairline's divs to the top of the z-ordering.
annotations.prototype.moveAnnotationToTop = function(a) {
var div = this.dygraph_.graphDiv;
$(a.infoDiv).appendTo(div);
$(a.lineDiv).appendTo(div);
var idx = this.annotations_.indexOf(a);
this.annotations_.splice(idx, 1);
this.annotations_.push(a);
};
// Positions existing hairline divs.
annotations.prototype.updateAnnotationDivPositions = function() {
var layout = this.dygraph_.getArea();
var chartLeft = layout.x, chartRight = layout.x + layout.w;
var chartTop = layout.y, chartBottom = layout.y + layout.h;
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);
var g = this.dygraph_;
var that = this;
$.each(this.annotations_, function(idx, a) {
var row_col = that.findPointIndex_(a.series, a.xval);
if (row_col == null) {
$([a.lineDiv, a.infoDiv]).hide();
return;
} else {
// TODO(danvk): only do this if they're invisible?
$([a.lineDiv, a.infoDiv]).show();
}
var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1]));
var x = xy[0], pointY = xy[1];
var lineHeight = 6; // TODO(danvk): option?
var y = pointY;
if (a.yFrac !== undefined) {
y = layout.y + layout.h * a.yFrac;
} else {
y -= lineHeight;
}
var lineHeight = y < pointY ? (pointY - y) : (y - pointY - a.infoDiv.offsetHeight);
$(a.lineDiv).css({
'left': x + 'px',
'top': Math.min(y, pointY) + 'px',
'height': lineHeight + 'px'
});
$(a.infoDiv).css({
'left': x + 'px',
});
if (!a.isDragging) {
// jQuery UI draggable likes to set 'top', whereas superannotations sets
// 'bottom'. Setting both will make the annotation grow and contract as
// the user drags it, which looks bad.
$(a.infoDiv).css({
'bottom': (div.offsetHeight - y) + 'px'
}) //.draggable("option", "containment", box);
var visible = (x >= chartLeft && x <= chartRight) &&
(pointY >= chartTop && pointY <= chartBottom);
$([a.infoDiv, a.lineDiv]).toggle(visible);
}
});
};
// Fills out the info div based on current coordinates.
annotations.prototype.updateAnnotationInfo = function() {
var g = this.dygraph_;
var that = this;
var templateDiv = $('#annotation-template').get(0);
$.each(this.annotations_, function(idx, a) {
// We should never update an editable div -- doing so may kill unsaved
// edits to an annotation.
$(a.infoDiv).toggleClass('editable', !!a.editable);
if (a.editable) return;
a.infoDiv.innerHTML = that.getTemplateHTML(templateDiv, a);
});
};
/**
* @param {!Annotation} a Internal annotation
* @return {!PublicAnnotation} a view of the annotation for the public API.
*/
annotations.prototype.createPublicAnnotation_ = function(a, opt_props) {
var displayAnnotation = $.extend({}, a, opt_props);
delete displayAnnotation['infoDiv'];
delete displayAnnotation['lineDiv'];
delete displayAnnotation['isDragging'];
delete displayAnnotation['editable'];
return displayAnnotation;
};
// Fill out a div using the values in the annotation object.
// The div's html is expected to have text of the form "{{key}}"
annotations.prototype.getTemplateHTML = function(div, a) {
var g = this.dygraph_;
var row_col = this.findPointIndex_(a.series, a.xval);
if (row_col == null) return; // perhaps it's no longer a real point?
var row = row_col[0];
var col = row_col[1];
var yOptView = g.optionsViewForAxis_('y1'); // TODO: support secondary, too
var xvf = g.getOptionForAxis('valueFormatter', 'x');
var x = xvf.call(g, a.xval);
var y = g.getOption('valueFormatter', a.series).call(
g, g.getValue(row, col), yOptView);
var displayAnnotation = this.createPublicAnnotation_(a, {x:x, y:y});
var html = div.innerHTML;
for (var k in displayAnnotation) {
var v = displayAnnotation[k];
if (typeof(v) == 'object') continue; // e.g. infoDiv or lineDiv
html = html.replace(new RegExp('\{\{' + k + '\}\}', 'g'), v);
}
return html;
};
// Update the annotation object by looking for elements with a 'dg-ann-field'
// attribute. For example, will have
// its value placed in the 'text' attribute of the annotation.
annotations.prototype.extractUpdatedProperties_ = function(div, a) {
$(div).find('[dg-ann-field]').each(function(idx, el) {
var k = $(el).attr('dg-ann-field');
var v = $(el).val();
a[k] = v;
});
};
// After a resize, the hairline divs can get dettached from the chart.
// This reattaches them.
annotations.prototype.attachAnnotationsToChart_ = function() {
var div = this.dygraph_.graphDiv;
$.each(this.annotations_, function(idx, a) {
// Re-attaching an editable div to the DOM can clear its focus.
// This makes typing really difficult!
if (a.editable) return;
$([a.lineDiv, a.infoDiv]).appendTo(div);
});
};
// Deletes a hairline and removes it from the chart.
annotations.prototype.removeAnnotation = function(a) {
var idx = this.annotations_.indexOf(a);
if (idx >= 0) {
this.annotations_.splice(idx, 1);
$([a.lineDiv, a.infoDiv]).remove();
} else {
Dygraph.warn('Tried to remove non-existent annotation.');
}
};
annotations.prototype.didDrawChart = function(e) {
var g = e.dygraph;
// Early out in the (common) case of zero annotations.
if (this.annotations_.length === 0) return;
this.updateAnnotationDivPositions();
this.attachAnnotationsToChart_();
this.updateAnnotationInfo();
};
annotations.prototype.pointClick = function(e) {
// Prevent any other behavior based on this click, e.g. creation of a hairline.
e.preventDefault();
var a = $.extend({}, this.defaultAnnotationProperties_, {
series: e.point.name,
xval: e.point.xval
});
this.annotations_.push(this.createAnnotation(a));
this.updateAnnotationDivPositions();
this.updateAnnotationInfo();
this.attachAnnotationsToChart_();
$(this).triggerHandler('annotationCreated', a);
$(this).triggerHandler('annotationsChanged', {});
// Annotations should begin life editable.
this.makeAnnotationEditable(a);
};
annotations.prototype.destroy = function() {
this.detachLabels();
};
// Public API
/**
* This is a restricted view of this.annotations_ which doesn't expose
* implementation details like the line / info divs.
*
* @typedef {
* xval: number, // x-value (i.e. millis or a raw number)
* series: string, // series name
* } PublicAnnotation
*/
/**
* @return {!Array.} The current set of annotations, ordered
* from back to front.
*/
annotations.prototype.get = function() {
var result = [];
for (var i = 0; i < this.annotations_.length; i++) {
result.push(this.createPublicAnnotation_(this.annotations_[i]));
}
return result;
};
/**
* Calling this will result in an annotationsChanged event being triggered, no
* matter whether it consists of additions, deletions, moves or no changes at
* all.
*
* @param {!Array.} annotations The new set of annotations,
* ordered from back to front.
*/
annotations.prototype.set = function(annotations) {
// Re-use divs from the old annotations array so far as we can.
// They're already correctly z-ordered.
var anyCreated = false;
for (var i = 0; i < annotations.length; i++) {
var a = annotations[i];
if (this.annotations_.length > i) {
// Only the divs need to be preserved.
var oldA = this.annotations_[i];
this.annotations_[i] = $.extend({
infoDiv: oldA.infoDiv,
lineDiv: oldA.lineDiv
}, a);
} else {
this.annotations_.push(this.createAnnotation(a));
anyCreated = true;
}
}
// If there are any remaining annotations, destroy them.
while (annotations.length < this.annotations_.length) {
this.removeAnnotation(this.annotations_[annotations.length]);
}
this.updateAnnotationDivPositions();
this.updateAnnotationInfo();
if (anyCreated) {
this.attachAnnotationsToChart_();
}
$(this).triggerHandler('annotationsChanged', {});
};
return annotations;
})();