| 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.call(g, a.xval); |
| 321 | var y = g.getOption('valueFormatter', a.series).call( |
| 322 | g, 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 | })(); |