| 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.Hairlines = (function() { |
| 15 | |
| 16 | "use strict"; |
| 17 | |
| 18 | /** |
| 19 | * @typedef { |
| 20 | * xval: number, // x-value (i.e. millis or a raw number) |
| 21 | * interpolated: bool, // alternative is to snap to closest |
| 22 | * lineDiv: !Element // vertical hairline div |
| 23 | * infoDiv: !Element // div containing info about the nearest points |
| 24 | * selected: boolean // whether this hairline is selected |
| 25 | * } Hairline |
| 26 | */ |
| 27 | |
| 28 | // We have to wait a few ms after clicks to give the user a chance to |
| 29 | // double-click to unzoom. This sets that delay period. |
| 30 | var CLICK_DELAY_MS = 300; |
| 31 | |
| 32 | var hairlines = function(opt_options) { |
| 33 | /* @type {!Array.<!Hairline>} */ |
| 34 | this.hairlines_ = []; |
| 35 | |
| 36 | // Used to detect resizes (which require the divs to be repositioned). |
| 37 | this.lastWidth_ = -1; |
| 38 | this.lastHeight = -1; |
| 39 | this.dygraph_ = null; |
| 40 | |
| 41 | this.addTimer_ = null; |
| 42 | opt_options = opt_options || {}; |
| 43 | |
| 44 | this.divFiller_ = opt_options['divFiller'] || null; |
| 45 | }; |
| 46 | |
| 47 | hairlines.prototype.toString = function() { |
| 48 | return "Hairlines Plugin"; |
| 49 | }; |
| 50 | |
| 51 | hairlines.prototype.activate = function(g) { |
| 52 | this.dygraph_ = g; |
| 53 | this.hairlines_ = []; |
| 54 | |
| 55 | return { |
| 56 | didDrawChart: this.didDrawChart, |
| 57 | click: this.click, |
| 58 | dblclick: this.dblclick, |
| 59 | dataDidUpdate: this.dataDidUpdate |
| 60 | }; |
| 61 | }; |
| 62 | |
| 63 | hairlines.prototype.detachLabels = function() { |
| 64 | for (var i = 0; i < this.hairlines_.length; i++) { |
| 65 | var h = this.hairlines_[i]; |
| 66 | $(h.lineDiv).remove(); |
| 67 | $(h.infoDiv).remove(); |
| 68 | this.hairlines_[i] = null; |
| 69 | } |
| 70 | this.hairlines_ = []; |
| 71 | }; |
| 72 | |
| 73 | hairlines.prototype.hairlineWasDragged = function(h, event, ui) { |
| 74 | var area = this.dygraph_.getArea(); |
| 75 | var oldXVal = h.xval; |
| 76 | h.xval = this.dygraph_.toDataXCoord(ui.position.left); |
| 77 | this.moveHairlineToTop(h); |
| 78 | this.updateHairlineDivPositions(); |
| 79 | this.updateHairlineInfo(); |
| 80 | this.updateHairlineStyles(); |
| 81 | $(this).triggerHandler('hairlineMoved', { |
| 82 | oldXVal: oldXVal, |
| 83 | newXVal: h.xval |
| 84 | }); |
| 85 | $(this).triggerHandler('hairlinesChanged', {}); |
| 86 | }; |
| 87 | |
| 88 | // This creates the hairline object and returns it. |
| 89 | // It does not position it and does not attach it to the chart. |
| 90 | hairlines.prototype.createHairline = function(props) { |
| 91 | var h; |
| 92 | var self = this; |
| 93 | |
| 94 | var $lineContainerDiv = $('<div/>').css({ |
| 95 | 'width': '6px', |
| 96 | 'margin-left': '-3px', |
| 97 | 'position': 'absolute', |
| 98 | 'z-index': '10' |
| 99 | }) |
| 100 | .addClass('dygraph-hairline'); |
| 101 | |
| 102 | var $lineDiv = $('<div/>').css({ |
| 103 | 'width': '1px', |
| 104 | 'position': 'relative', |
| 105 | 'left': '3px', |
| 106 | 'background': 'black', |
| 107 | 'height': '100%' |
| 108 | }); |
| 109 | $lineDiv.appendTo($lineContainerDiv); |
| 110 | |
| 111 | var $infoDiv = $('#hairline-template').clone().removeAttr('id').css({ |
| 112 | 'position': 'absolute' |
| 113 | }) |
| 114 | .show(); |
| 115 | |
| 116 | // Surely there's a more jQuery-ish way to do this! |
| 117 | $([$infoDiv.get(0), $lineContainerDiv.get(0)]) |
| 118 | .draggable({ |
| 119 | 'axis': 'x', |
| 120 | 'drag': function(event, ui) { |
| 121 | self.hairlineWasDragged(h, event, ui); |
| 122 | } |
| 123 | // TODO(danvk): set cursor here |
| 124 | }); |
| 125 | |
| 126 | h = $.extend({ |
| 127 | interpolated: true, |
| 128 | selected: false, |
| 129 | lineDiv: $lineContainerDiv.get(0), |
| 130 | infoDiv: $infoDiv.get(0) |
| 131 | }, props); |
| 132 | |
| 133 | var that = this; |
| 134 | $infoDiv.on('click', '.hairline-kill-button', function(e) { |
| 135 | that.removeHairline(h); |
| 136 | $(that).triggerHandler('hairlineDeleted', { |
| 137 | xval: h.xval |
| 138 | }); |
| 139 | $(that).triggerHandler('hairlinesChanged', {}); |
| 140 | e.stopPropagation(); // don't want .click() to trigger, below. |
| 141 | }).on('click', function() { |
| 142 | that.moveHairlineToTop(h); |
| 143 | }); |
| 144 | |
| 145 | return h; |
| 146 | }; |
| 147 | |
| 148 | // Moves a hairline's divs to the top of the z-ordering. |
| 149 | hairlines.prototype.moveHairlineToTop = function(h) { |
| 150 | var div = this.dygraph_.graphDiv; |
| 151 | $(h.infoDiv).appendTo(div); |
| 152 | $(h.lineDiv).appendTo(div); |
| 153 | |
| 154 | var idx = this.hairlines_.indexOf(h); |
| 155 | this.hairlines_.splice(idx, 1); |
| 156 | this.hairlines_.push(h); |
| 157 | }; |
| 158 | |
| 159 | // Positions existing hairline divs. |
| 160 | hairlines.prototype.updateHairlineDivPositions = function() { |
| 161 | var g = this.dygraph_; |
| 162 | var layout = this.dygraph_.getArea(); |
| 163 | var chartLeft = layout.x, chartRight = layout.x + layout.w; |
| 164 | var div = this.dygraph_.graphDiv; |
| 165 | var pos = Dygraph.findPos(div); |
| 166 | var box = [layout.x + pos.x, layout.y + pos.y]; |
| 167 | box.push(box[0] + layout.w); |
| 168 | box.push(box[1] + layout.h); |
| 169 | |
| 170 | $.each(this.hairlines_, function(idx, h) { |
| 171 | var left = g.toDomXCoord(h.xval); |
| 172 | h.domX = left; // See comments in this.dataDidUpdate |
| 173 | $(h.lineDiv).css({ |
| 174 | 'left': left + 'px', |
| 175 | 'top': layout.y + 'px', |
| 176 | 'height': layout.h + 'px' |
| 177 | }); // .draggable("option", "containment", box); |
| 178 | $(h.infoDiv).css({ |
| 179 | 'left': left + 'px', |
| 180 | 'top': layout.y + 'px', |
| 181 | }).draggable("option", "containment", box); |
| 182 | |
| 183 | var visible = (left >= chartLeft && left <= chartRight); |
| 184 | $([h.infoDiv, h.lineDiv]).toggle(visible); |
| 185 | }); |
| 186 | }; |
| 187 | |
| 188 | // Sets styles on the hairline (i.e. "selected") |
| 189 | hairlines.prototype.updateHairlineStyles = function() { |
| 190 | $.each(this.hairlines_, function(idx, h) { |
| 191 | $([h.infoDiv, h.lineDiv]).toggleClass('selected', h.selected); |
| 192 | }); |
| 193 | }; |
| 194 | |
| 195 | // Find prevRow and nextRow such that |
| 196 | // g.getValue(prevRow, 0) <= xval |
| 197 | // g.getValue(nextRow, 0) >= xval |
| 198 | // g.getValue({prev,next}Row, col) != null, NaN or undefined |
| 199 | // and there's no other row such that: |
| 200 | // g.getValue(prevRow, 0) < g.getValue(row, 0) < g.getValue(nextRow, 0) |
| 201 | // g.getValue(row, col) != null, NaN or undefined. |
| 202 | // Returns [prevRow, nextRow]. Either can be null (but not both). |
| 203 | hairlines.findPrevNextRows = function(g, xval, col) { |
| 204 | var prevRow = null, nextRow = null; |
| 205 | var numRows = g.numRows(); |
| 206 | for (var row = 0; row < numRows; row++) { |
| 207 | var yval = g.getValue(row, col); |
| 208 | if (yval === null || yval === undefined || isNaN(yval)) continue; |
| 209 | |
| 210 | var rowXval = g.getValue(row, 0); |
| 211 | if (rowXval <= xval) prevRow = row; |
| 212 | |
| 213 | if (rowXval >= xval) { |
| 214 | nextRow = row; |
| 215 | break; |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | return [prevRow, nextRow]; |
| 220 | }; |
| 221 | |
| 222 | // Fills out the info div based on current coordinates. |
| 223 | hairlines.prototype.updateHairlineInfo = function() { |
| 224 | var mode = 'closest'; |
| 225 | |
| 226 | var g = this.dygraph_; |
| 227 | var xRange = g.xAxisRange(); |
| 228 | var that = this; |
| 229 | $.each(this.hairlines_, function(idx, h) { |
| 230 | // To use generateLegendHTML, we synthesize an array of selected points. |
| 231 | var selPoints = []; |
| 232 | var labels = g.getLabels(); |
| 233 | var row, prevRow, nextRow; |
| 234 | |
| 235 | if (!h.interpolated) { |
| 236 | // "closest point" mode. |
| 237 | // TODO(danvk): make findClosestRow method public |
| 238 | row = g.findClosestRow(g.toDomXCoord(h.xval)); |
| 239 | for (var i = 1; i < g.numColumns(); i++) { |
| 240 | selPoints.push({ |
| 241 | canvasx: 1, // TODO(danvk): real coordinate |
| 242 | canvasy: 1, // TODO(danvk): real coordinate |
| 243 | xval: h.xval, |
| 244 | yval: g.getValue(row, i), |
| 245 | name: labels[i] |
| 246 | }); |
| 247 | } |
| 248 | } else { |
| 249 | // "interpolated" mode. |
| 250 | for (var i = 1; i < g.numColumns(); i++) { |
| 251 | var prevNextRow = hairlines.findPrevNextRows(g, h.xval, i); |
| 252 | prevRow = prevNextRow[0], nextRow = prevNextRow[1]; |
| 253 | |
| 254 | // For x-values outside the domain, interpolate "between" the extreme |
| 255 | // point and itself. |
| 256 | if (prevRow === null) prevRow = nextRow; |
| 257 | if (nextRow === null) nextRow = prevRow; |
| 258 | |
| 259 | // linear interpolation |
| 260 | var prevX = g.getValue(prevRow, 0), |
| 261 | nextX = g.getValue(nextRow, 0), |
| 262 | prevY = g.getValue(prevRow, i), |
| 263 | nextY = g.getValue(nextRow, i), |
| 264 | frac = prevRow == nextRow ? 0 : (h.xval - prevX) / (nextX - prevX), |
| 265 | yval = frac * nextY + (1 - frac) * prevY; |
| 266 | |
| 267 | selPoints.push({ |
| 268 | canvasx: 1, // TODO(danvk): real coordinate |
| 269 | canvasy: 1, // TODO(danvk): real coordinate |
| 270 | xval: h.xval, |
| 271 | yval: yval, |
| 272 | prevRow: prevRow, |
| 273 | nextRow: nextRow, |
| 274 | name: labels[i] |
| 275 | }); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | if (that.divFiller_) { |
| 280 | that.divFiller_(h.infoDiv, { |
| 281 | closestRow: row, |
| 282 | points: selPoints, |
| 283 | hairline: that.createPublicHairline_(h), |
| 284 | dygraph: g |
| 285 | }); |
| 286 | } else { |
| 287 | var html = Dygraph.Plugins.Legend.generateLegendHTML(g, h.xval, selPoints, 10); |
| 288 | $('.hairline-legend', h.infoDiv).html(html); |
| 289 | } |
| 290 | }); |
| 291 | }; |
| 292 | |
| 293 | // After a resize, the hairline divs can get dettached from the chart. |
| 294 | // This reattaches them. |
| 295 | hairlines.prototype.attachHairlinesToChart_ = function() { |
| 296 | var div = this.dygraph_.graphDiv; |
| 297 | $.each(this.hairlines_, function(idx, h) { |
| 298 | $([h.lineDiv, h.infoDiv]).appendTo(div); |
| 299 | }); |
| 300 | }; |
| 301 | |
| 302 | // Deletes a hairline and removes it from the chart. |
| 303 | hairlines.prototype.removeHairline = function(h) { |
| 304 | var idx = this.hairlines_.indexOf(h); |
| 305 | if (idx >= 0) { |
| 306 | this.hairlines_.splice(idx, 1); |
| 307 | $([h.lineDiv, h.infoDiv]).remove(); |
| 308 | } else { |
| 309 | Dygraph.warn('Tried to remove non-existent hairline.'); |
| 310 | } |
| 311 | }; |
| 312 | |
| 313 | hairlines.prototype.didDrawChart = function(e) { |
| 314 | var g = e.dygraph; |
| 315 | |
| 316 | // Early out in the (common) case of zero hairlines. |
| 317 | if (this.hairlines_.length === 0) return; |
| 318 | |
| 319 | this.updateHairlineDivPositions(); |
| 320 | this.attachHairlinesToChart_(); |
| 321 | this.updateHairlineInfo(); |
| 322 | this.updateHairlineStyles(); |
| 323 | }; |
| 324 | |
| 325 | hairlines.prototype.dataDidUpdate = function(e) { |
| 326 | // When the data in the chart updates, the hairlines should stay in the same |
| 327 | // position on the screen. didDrawChart stores a domX parameter for each |
| 328 | // hairline. We use that to reposition them on data updates. |
| 329 | var g = this.dygraph_; |
| 330 | $.each(this.hairlines_, function(idx, h) { |
| 331 | if (h.hasOwnProperty('domX')) { |
| 332 | h.xval = g.toDataXCoord(h.domX); |
| 333 | } |
| 334 | }); |
| 335 | }; |
| 336 | |
| 337 | hairlines.prototype.click = function(e) { |
| 338 | if (this.addTimer_) { |
| 339 | // Another click is in progress; ignore this one. |
| 340 | return; |
| 341 | } |
| 342 | |
| 343 | var area = e.dygraph.getArea(); |
| 344 | var xval = this.dygraph_.toDataXCoord(e.canvasx); |
| 345 | |
| 346 | var that = this; |
| 347 | this.addTimer_ = setTimeout(function() { |
| 348 | that.addTimer_ = null; |
| 349 | that.hairlines_.push(that.createHairline({xval: xval})); |
| 350 | |
| 351 | that.updateHairlineDivPositions(); |
| 352 | that.updateHairlineInfo(); |
| 353 | that.updateHairlineStyles(); |
| 354 | that.attachHairlinesToChart_(); |
| 355 | |
| 356 | $(that).triggerHandler('hairlineCreated', { |
| 357 | xval: xval |
| 358 | }); |
| 359 | $(that).triggerHandler('hairlinesChanged', {}); |
| 360 | }, CLICK_DELAY_MS); |
| 361 | }; |
| 362 | |
| 363 | hairlines.prototype.dblclick = function(e) { |
| 364 | if (this.addTimer_) { |
| 365 | clearTimeout(this.addTimer_); |
| 366 | this.addTimer_ = null; |
| 367 | } |
| 368 | }; |
| 369 | |
| 370 | hairlines.prototype.destroy = function() { |
| 371 | this.detachLabels(); |
| 372 | }; |
| 373 | |
| 374 | |
| 375 | // Public API |
| 376 | |
| 377 | /** |
| 378 | * This is a restricted view of this.hairlines_ which doesn't expose |
| 379 | * implementation details like the handle divs. |
| 380 | * |
| 381 | * @typedef { |
| 382 | * xval: number, // x-value (i.e. millis or a raw number) |
| 383 | * interpolated: bool, // alternative is to snap to closest |
| 384 | * selected: bool // whether the hairline is selected. |
| 385 | * } PublicHairline |
| 386 | */ |
| 387 | |
| 388 | /** |
| 389 | * @param {!Hairline} h Internal hairline. |
| 390 | * @return {!PublicHairline} Restricted public view of the hairline. |
| 391 | */ |
| 392 | hairlines.prototype.createPublicHairline_ = function(h) { |
| 393 | return { |
| 394 | xval: h.xval, |
| 395 | interpolated: h.interpolated, |
| 396 | selected: h.selected |
| 397 | }; |
| 398 | }; |
| 399 | |
| 400 | /** |
| 401 | * @return {!Array.<!PublicHairline>} The current set of hairlines, ordered |
| 402 | * from back to front. |
| 403 | */ |
| 404 | hairlines.prototype.get = function() { |
| 405 | var result = []; |
| 406 | for (var i = 0; i < this.hairlines_.length; i++) { |
| 407 | var h = this.hairlines_[i]; |
| 408 | result.push(this.createPublicHairline_(h)); |
| 409 | } |
| 410 | return result; |
| 411 | }; |
| 412 | |
| 413 | /** |
| 414 | * Calling this will result in a hairlinesChanged event being triggered, no |
| 415 | * matter whether it consists of additions, deletions, moves or no changes at |
| 416 | * all. |
| 417 | * |
| 418 | * @param {!Array.<!PublicHairline>} hairlines The new set of hairlines, |
| 419 | * ordered from back to front. |
| 420 | */ |
| 421 | hairlines.prototype.set = function(hairlines) { |
| 422 | // Re-use divs from the old hairlines array so far as we can. |
| 423 | // They're already correctly z-ordered. |
| 424 | var anyCreated = false; |
| 425 | for (var i = 0; i < hairlines.length; i++) { |
| 426 | var h = hairlines[i]; |
| 427 | |
| 428 | if (this.hairlines_.length > i) { |
| 429 | this.hairlines_[i].xval = h.xval; |
| 430 | this.hairlines_[i].interpolated = h.interpolated; |
| 431 | this.hairlines_[i].selected = h.selected; |
| 432 | } else { |
| 433 | this.hairlines_.push(this.createHairline({ |
| 434 | xval: h.xval, |
| 435 | interpolated: h.interpolated, |
| 436 | selected: h.selected |
| 437 | })); |
| 438 | anyCreated = true; |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | // If there are any remaining hairlines, destroy them. |
| 443 | while (hairlines.length < this.hairlines_.length) { |
| 444 | this.removeHairline(this.hairlines_[hairlines.length]); |
| 445 | } |
| 446 | |
| 447 | this.updateHairlineDivPositions(); |
| 448 | this.updateHairlineInfo(); |
| 449 | this.updateHairlineStyles(); |
| 450 | if (anyCreated) { |
| 451 | this.attachHairlinesToChart_(); |
| 452 | } |
| 453 | |
| 454 | $(this).triggerHandler('hairlinesChanged', {}); |
| 455 | }; |
| 456 | |
| 457 | return hairlines; |
| 458 | |
| 459 | })(); |