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.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 | })(); |