documentation update
[dygraphs.git] / extras / super-annotations.js
1 /**
2 * @license
3 * Copyright 2013 Dan Vanderkam (danvdk@gmail.com)
4 * MIT-licensed (http://opensource.org/licenses/MIT)
5 *
6 * Note: This plugin requires jQuery and jQuery UI Draggable.
7 *
8 * See high-level documentation at
9 * https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit#
10 */
11
12 /*global Dygraph:false */
13
14 Dygraph.Plugins.SuperAnnotations = (function() {
15
16 "use strict";
17
18 /**
19 * These are just the basic requirements -- annotations can have whatever other
20 * properties the code that displays them wants them to have.
21 *
22 * @typedef {
23 * xval: number, // x-value (i.e. millis or a raw number)
24 * series: string, // series name
25 * yFrac: ?number, // y-positioning. Default is a few px above the point.
26 * lineDiv: !Element // vertical div connecting point to info div.
27 * infoDiv: !Element // div containing info about the annotation.
28 * } Annotation
29 */
30
31 var annotations = function(opt_options) {
32 /* @type {!Array.<!Annotation>} */
33 this.annotations_ = [];
34 // Used to detect resizes (which require the divs to be repositioned).
35 this.lastWidth_ = -1;
36 this.lastHeight = -1;
37 this.dygraph_ = null;
38
39 opt_options = opt_options || {};
40 this.defaultAnnotationProperties_ = $.extend({
41 'text': 'Description'
42 }, opt_options['defaultAnnotationProperties']);
43 };
44
45 annotations.prototype.toString = function() {
46 return "SuperAnnotations Plugin";
47 };
48
49 annotations.prototype.activate = function(g) {
50 this.dygraph_ = g;
51 this.annotations_ = [];
52
53 return {
54 didDrawChart: this.didDrawChart,
55 pointClick: this.pointClick // TODO(danvk): implement in dygraphs
56 };
57 };
58
59 annotations.prototype.detachLabels = function() {
60 for (var i = 0; i < this.annotations_.length; i++) {
61 var a = this.annotations_[i];
62 $(a.lineDiv).remove();
63 $(a.infoDiv).remove();
64 this.annotations_[i] = null;
65 }
66 this.annotations_ = [];
67 };
68
69 annotations.prototype.annotationWasDragged = function(a, event, ui) {
70 var g = this.dygraph_;
71 var area = g.getArea();
72 var oldYFrac = a.yFrac;
73
74 var infoDiv = a.infoDiv;
75 var newYFrac = ((infoDiv.offsetTop + infoDiv.offsetHeight) - area.y) / area.h;
76 if (newYFrac == oldYFrac) return;
77
78 a.yFrac = newYFrac;
79
80 this.moveAnnotationToTop(a);
81 this.updateAnnotationDivPositions();
82 this.updateAnnotationInfo();
83 $(this).triggerHandler('annotationMoved', {
84 annotation: a,
85 oldYFrac: oldYFrac,
86 newYFrac: a.yFrac
87 });
88 $(this).triggerHandler('annotationsChanged', {});
89 };
90
91 annotations.prototype.makeAnnotationEditable = function(a) {
92 if (a.editable == true) return;
93 this.moveAnnotationToTop(a);
94
95 // Note: we have to fill out the HTML ourselves because
96 // updateAnnotationInfo() won't touch editable annotations.
97 a.editable = true;
98 var editableTemplateDiv = $('#annotation-editable-template').get(0);
99 a.infoDiv.innerHTML = this.getTemplateHTML(editableTemplateDiv, a);
100 $(a.infoDiv).toggleClass('editable', !!a.editable);
101 $(this).triggerHandler('beganEditAnnotation', a);
102 };
103
104 // This creates the hairline object and returns it.
105 // It does not position it and does not attach it to the chart.
106 annotations.prototype.createAnnotation = function(a) {
107 var self = this;
108
109 var color = this.getColorForSeries_(a.series);
110
111 var $lineDiv = $('<div/>').css({
112 'width': '1px',
113 'left': '3px',
114 'background': 'black',
115 'height': '100%',
116 'position': 'absolute',
117 // TODO(danvk): use border-color here for consistency?
118 'background-color': color,
119 'z-index': 10
120 }).addClass('dygraph-annotation-line');
121
122 var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({
123 'position': 'absolute',
124 'border-color': color,
125 'z-index': 10
126 })
127 .show();
128
129 $.extend(a, {
130 lineDiv: $lineDiv.get(0),
131 infoDiv: $infoDiv.get(0)
132 });
133
134 var that = this;
135
136 $infoDiv.draggable({
137 'start': function(event, ui) {
138 $(this).css({'bottom': ''});
139 a.isDragging = true;
140 },
141 'drag': function(event, ui) {
142 self.annotationWasDragged(a, event, ui);
143 },
144 'stop': function(event, ui) {
145 $(this).css({'top': ''});
146 a.isDragging = false;
147 self.updateAnnotationDivPositions();
148 },
149 'axis': 'y',
150 'containment': 'parent'
151 });
152
153 // TODO(danvk): use 'on' instead of delegate/dblclick
154 $infoDiv.on('click', '.annotation-kill-button', function() {
155 that.removeAnnotation(a);
156 $(that).triggerHandler('annotationDeleted', a);
157 $(that).triggerHandler('annotationsChanged', {});
158 });
159
160 $infoDiv.on('dblclick', function() {
161 that.makeAnnotationEditable(a);
162 });
163 $infoDiv.on('click', '.annotation-update', function() {
164 self.extractUpdatedProperties_($infoDiv.get(0), a);
165 a.editable = false;
166 self.updateAnnotationInfo();
167 $(that).triggerHandler('annotationEdited', a);
168 $(that).triggerHandler('annotationsChanged', {});
169 });
170 $infoDiv.on('click', '.annotation-cancel', function() {
171 a.editable = false;
172 self.updateAnnotationInfo();
173 $(that).triggerHandler('cancelEditAnnotation', a);
174 });
175
176 return a;
177 };
178
179 // Find the index of a point in a series.
180 // Returns a 2-element array, [row, col], which can be used with
181 // dygraph.getValue() to get the value at this point.
182 // Returns null if there's no match.
183 annotations.prototype.findPointIndex_ = function(series, xval) {
184 var col = this.dygraph_.getLabels().indexOf(series);
185 if (col == -1) return null;
186
187 var lowIdx = 0, highIdx = this.dygraph_.numRows() - 1;
188 while (lowIdx <= highIdx) {
189 var idx = Math.floor((lowIdx + highIdx) / 2);
190 var xAtIdx = this.dygraph_.getValue(idx, 0);
191 if (xAtIdx == xval) {
192 return [idx, col];
193 } else if (xAtIdx < xval) {
194 lowIdx = idx + 1;
195 } else {
196 highIdx = idx - 1;
197 }
198 }
199 return null;
200 };
201
202 annotations.prototype.getColorForSeries_ = function(series) {
203 var colors = this.dygraph_.getColors();
204 var col = this.dygraph_.getLabels().indexOf(series);
205 if (col == -1) return null;
206
207 return colors[(col - 1) % colors.length];
208 };
209
210 // Moves a hairline's divs to the top of the z-ordering.
211 annotations.prototype.moveAnnotationToTop = function(a) {
212 var div = this.dygraph_.graphDiv;
213 $(a.infoDiv).appendTo(div);
214 $(a.lineDiv).appendTo(div);
215
216 var idx = this.annotations_.indexOf(a);
217 this.annotations_.splice(idx, 1);
218 this.annotations_.push(a);
219 };
220
221 // Positions existing hairline divs.
222 annotations.prototype.updateAnnotationDivPositions = function() {
223 var layout = this.dygraph_.getArea();
224 var chartLeft = layout.x, chartRight = layout.x + layout.w;
225 var chartTop = layout.y, chartBottom = layout.y + layout.h;
226 var div = this.dygraph_.graphDiv;
227 var pos = Dygraph.findPos(div);
228 var box = [layout.x + pos.x, layout.y + pos.y];
229 box.push(box[0] + layout.w);
230 box.push(box[1] + layout.h);
231
232 var g = this.dygraph_;
233
234 var that = this;
235 $.each(this.annotations_, function(idx, a) {
236 var row_col = that.findPointIndex_(a.series, a.xval);
237 if (row_col == null) {
238 $([a.lineDiv, a.infoDiv]).hide();
239 return;
240 } else {
241 // TODO(danvk): only do this if they're invisible?
242 $([a.lineDiv, a.infoDiv]).show();
243 }
244 var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1]));
245 var x = xy[0], pointY = xy[1];
246
247 var lineHeight = 6; // TODO(danvk): option?
248
249 var y = pointY;
250 if (a.yFrac !== undefined) {
251 y = layout.y + layout.h * a.yFrac;
252 } else {
253 y -= lineHeight;
254 }
255
256 var lineHeight = y < pointY ? (pointY - y) : (y - pointY - a.infoDiv.offsetHeight);
257 $(a.lineDiv).css({
258 'left': x + 'px',
259 'top': Math.min(y, pointY) + 'px',
260 'height': lineHeight + 'px'
261 });
262 $(a.infoDiv).css({
263 'left': x + 'px',
264 });
265 if (!a.isDragging) {
266 // jQuery UI draggable likes to set 'top', whereas superannotations sets
267 // 'bottom'. Setting both will make the annotation grow and contract as
268 // the user drags it, which looks bad.
269 $(a.infoDiv).css({
270 'bottom': (div.offsetHeight - y) + 'px'
271 }) //.draggable("option", "containment", box);
272
273 var visible = (x >= chartLeft && x <= chartRight) &&
274 (pointY >= chartTop && pointY <= chartBottom);
275 $([a.infoDiv, a.lineDiv]).toggle(visible);
276 }
277 });
278 };
279
280 // Fills out the info div based on current coordinates.
281 annotations.prototype.updateAnnotationInfo = function() {
282 var g = this.dygraph_;
283
284 var that = this;
285 var templateDiv = $('#annotation-template').get(0);
286 $.each(this.annotations_, function(idx, a) {
287 // We should never update an editable div -- doing so may kill unsaved
288 // edits to an annotation.
289 $(a.infoDiv).toggleClass('editable', !!a.editable);
290 if (a.editable) return;
291 a.infoDiv.innerHTML = that.getTemplateHTML(templateDiv, a);
292 });
293 };
294
295 /**
296 * @param {!Annotation} a Internal annotation
297 * @return {!PublicAnnotation} a view of the annotation for the public API.
298 */
299 annotations.prototype.createPublicAnnotation_ = function(a, opt_props) {
300 var displayAnnotation = $.extend({}, a, opt_props);
301 delete displayAnnotation['infoDiv'];
302 delete displayAnnotation['lineDiv'];
303 delete displayAnnotation['isDragging'];
304 delete displayAnnotation['editable'];
305 return displayAnnotation;
306 };
307
308 // Fill out a div using the values in the annotation object.
309 // The div's html is expected to have text of the form "{{key}}"
310 annotations.prototype.getTemplateHTML = function(div, a) {
311 var g = this.dygraph_;
312 var row_col = this.findPointIndex_(a.series, a.xval);
313 if (row_col == null) return; // perhaps it's no longer a real point?
314 var row = row_col[0];
315 var col = row_col[1];
316
317 var yOptView = g.optionsViewForAxis_('y1'); // TODO: support secondary, too
318 var xvf = g.getOptionForAxis('valueFormatter', 'x');
319
320 var x = xvf(a.xval);
321 var y = g.getOption('valueFormatter', a.series)(
322 g.getValue(row, col), yOptView);
323
324 var displayAnnotation = this.createPublicAnnotation_(a, {x:x, y:y});
325 var html = div.innerHTML;
326 for (var k in displayAnnotation) {
327 var v = displayAnnotation[k];
328 if (typeof(v) == 'object') continue; // e.g. infoDiv or lineDiv
329 html = html.replace(new RegExp('\{\{' + k + '\}\}', 'g'), v);
330 }
331 return html;
332 };
333
334 // Update the annotation object by looking for elements with a 'dg-ann-field'
335 // attribute. For example, <input type='text' dg-ann-field='text' /> will have
336 // its value placed in the 'text' attribute of the annotation.
337 annotations.prototype.extractUpdatedProperties_ = function(div, a) {
338 $(div).find('[dg-ann-field]').each(function(idx, el) {
339 var k = $(el).attr('dg-ann-field');
340 var v = $(el).val();
341 a[k] = v;
342 });
343 };
344
345 // After a resize, the hairline divs can get dettached from the chart.
346 // This reattaches them.
347 annotations.prototype.attachAnnotationsToChart_ = function() {
348 var div = this.dygraph_.graphDiv;
349 $.each(this.annotations_, function(idx, a) {
350 // Re-attaching an editable div to the DOM can clear its focus.
351 // This makes typing really difficult!
352 if (a.editable) return;
353
354 $([a.lineDiv, a.infoDiv]).appendTo(div);
355 });
356 };
357
358 // Deletes a hairline and removes it from the chart.
359 annotations.prototype.removeAnnotation = function(a) {
360 var idx = this.annotations_.indexOf(a);
361 if (idx >= 0) {
362 this.annotations_.splice(idx, 1);
363 $([a.lineDiv, a.infoDiv]).remove();
364 } else {
365 Dygraph.warn('Tried to remove non-existent annotation.');
366 }
367 };
368
369 annotations.prototype.didDrawChart = function(e) {
370 var g = e.dygraph;
371
372 // Early out in the (common) case of zero annotations.
373 if (this.annotations_.length === 0) return;
374
375 this.updateAnnotationDivPositions();
376 this.attachAnnotationsToChart_();
377 this.updateAnnotationInfo();
378 };
379
380 annotations.prototype.pointClick = function(e) {
381 // Prevent any other behavior based on this click, e.g. creation of a hairline.
382 e.preventDefault();
383
384 var a = $.extend({}, this.defaultAnnotationProperties_, {
385 series: e.point.name,
386 xval: e.point.xval
387 });
388 this.annotations_.push(this.createAnnotation(a));
389
390 this.updateAnnotationDivPositions();
391 this.updateAnnotationInfo();
392 this.attachAnnotationsToChart_();
393
394 $(this).triggerHandler('annotationCreated', a);
395 $(this).triggerHandler('annotationsChanged', {});
396
397 // Annotations should begin life editable.
398 this.makeAnnotationEditable(a);
399 };
400
401 annotations.prototype.destroy = function() {
402 this.detachLabels();
403 };
404
405
406 // Public API
407
408 /**
409 * This is a restricted view of this.annotations_ which doesn't expose
410 * implementation details like the line / info divs.
411 *
412 * @typedef {
413 * xval: number, // x-value (i.e. millis or a raw number)
414 * series: string, // series name
415 * } PublicAnnotation
416 */
417
418 /**
419 * @return {!Array.<!PublicAnnotation>} The current set of annotations, ordered
420 * from back to front.
421 */
422 annotations.prototype.get = function() {
423 var result = [];
424 for (var i = 0; i < this.annotations_.length; i++) {
425 result.push(this.createPublicAnnotation_(this.annotations_[i]));
426 }
427 return result;
428 };
429
430 /**
431 * Calling this will result in an annotationsChanged event being triggered, no
432 * matter whether it consists of additions, deletions, moves or no changes at
433 * all.
434 *
435 * @param {!Array.<!PublicAnnotation>} annotations The new set of annotations,
436 * ordered from back to front.
437 */
438 annotations.prototype.set = function(annotations) {
439 // Re-use divs from the old annotations array so far as we can.
440 // They're already correctly z-ordered.
441 var anyCreated = false;
442 for (var i = 0; i < annotations.length; i++) {
443 var a = annotations[i];
444
445 if (this.annotations_.length > i) {
446 // Only the divs need to be preserved.
447 var oldA = this.annotations_[i];
448 this.annotations_[i] = $.extend({
449 infoDiv: oldA.infoDiv,
450 lineDiv: oldA.lineDiv
451 }, a);
452 } else {
453 this.annotations_.push(this.createAnnotation(a));
454 anyCreated = true;
455 }
456 }
457
458 // If there are any remaining annotations, destroy them.
459 while (annotations.length < this.annotations_.length) {
460 this.removeAnnotation(this.annotations_[annotations.length]);
461 }
462
463 this.updateAnnotationDivPositions();
464 this.updateAnnotationInfo();
465 if (anyCreated) {
466 this.attachAnnotationsToChart_();
467 }
468
469 $(this).triggerHandler('annotationsChanged', {});
470 };
471
472 return annotations;
473
474 })();