Commit | Line | Data |
---|---|---|
80be397c DV |
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 | })(); |