3 * Copyright 2013 Dan Vanderkam (danvdk@gmail.com)
4 * MIT-licensed (http://opensource.org/licenses/MIT)
6 * Note: This plugin requires jQuery and jQuery UI Draggable.
8 * See high-level documentation at
9 * https://docs.google.com/document/d/1OHNE8BNNmMtFlRQ969DACIYIJ9VVJ7w3dSPRJDEeIew/edit#
12 /*global Dygraph:false */
14 Dygraph
.Plugins
.Hairlines
= (function() {
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
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;
32 var hairlines
= function(opt_options
) {
33 /* @type {!Array.<!Hairline>} */
36 // Used to detect resizes (which require the divs to be repositioned).
41 this.addTimer_
= null;
42 opt_options
= opt_options
|| {};
44 this.divFiller_
= opt_options
['divFiller'] || null;
47 hairlines
.prototype.toString
= function() {
48 return "Hairlines Plugin";
51 hairlines
.prototype.activate
= function(g
) {
56 didDrawChart
: this.didDrawChart
,
58 dblclick
: this.dblclick
,
59 dataDidUpdate
: this.dataDidUpdate
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;
73 hairlines
.prototype.hairlineWasDragged
= function(h
, event
, ui
) {
74 var area
= this.dygraph_
.getArea();
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', {
85 $(this).triggerHandler('hairlinesChanged', {});
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
) {
94 var $lineContainerDiv
= $('<div/>').css({
96 'margin-left': '-3px',
97 'position': 'absolute',
100 .addClass('dygraph-hairline');
102 var $lineDiv
= $('<div/>').css({
104 'position': 'relative',
106 'background': 'black',
109 $lineDiv
.appendTo($lineContainerDiv
);
111 var $infoDiv
= $('#hairline-template').clone().removeAttr('id').css({
112 'position': 'absolute'
116 // Surely there's a more jQuery-ish way to do this!
117 $([$infoDiv
.get(0), $lineContainerDiv
.get(0)])
120 'drag': function(event
, ui
) {
121 self
.hairlineWasDragged(h
, event
, ui
);
123 // TODO(danvk): set cursor here
129 lineDiv
: $lineContainerDiv
.get(0),
130 infoDiv
: $infoDiv
.get(0)
134 $infoDiv
.on('click', '.hairline-kill-button', function(e
) {
135 that
.removeHairline(h
);
136 $(that
).triggerHandler('hairlineDeleted', {
139 $(that
).triggerHandler('hairlinesChanged', {});
140 e
.stopPropagation(); // don't want .click() to trigger, below.
141 }).on('click', function() {
142 that
.moveHairlineToTop(h
);
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
);
154 var idx
= this.hairlines_
.indexOf(h
);
155 this.hairlines_
.splice(idx
, 1);
156 this.hairlines_
.push(h
);
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
);
170 $.each(this.hairlines_
, function(idx
, h
) {
171 var left
= g
.toDomXCoord(h
.xval
);
172 h
.domX
= left
; // See comments in this.dataDidUpdate
175 'top': layout
.y
+ 'px',
176 'height': layout
.h
+ 'px'
177 }); // .draggable("option", "containment", box);
180 'top': layout
.y
+ 'px',
181 }).draggable("option", "containment", box
);
183 var visible
= (left
>= chartLeft
&& left
<= chartRight
);
184 $([h
.infoDiv
, h
.lineDiv
]).toggle(visible
);
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
);
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;
210 var rowXval
= g
.getValue(row
, 0);
211 if (rowXval
<= xval
) prevRow
= row
;
213 if (rowXval
>= xval
) {
219 return [prevRow
, nextRow
];
222 // Fills out the info div based on current coordinates.
223 hairlines
.prototype.updateHairlineInfo
= function() {
224 var mode
= 'closest';
226 var g
= this.dygraph_
;
227 var xRange
= g
.xAxisRange();
229 $.each(this.hairlines_
, function(idx
, h
) {
230 // To use generateLegendHTML, we synthesize an array of selected points.
232 var labels
= g
.getLabels();
233 var row
, prevRow
, nextRow
;
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
++) {
241 canvasx
: 1, // TODO(danvk): real coordinate
242 canvasy
: 1, // TODO(danvk): real coordinate
244 yval
: g
.getValue(row
, i
),
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];
254 // For x-values outside the domain, interpolate "between" the extreme
256 if (prevRow
=== null) prevRow
= nextRow
;
257 if (nextRow
=== null) nextRow
= prevRow
;
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
;
268 canvasx
: 1, // TODO(danvk): real coordinate
269 canvasy
: 1, // TODO(danvk): real coordinate
279 if (that
.divFiller_
) {
280 that
.divFiller_(h
.infoDiv
, {
283 hairline
: that
.createPublicHairline_(h
),
287 var html
= Dygraph
.Plugins
.Legend
.generateLegendHTML(g
, h
.xval
, selPoints
, 10);
288 $('.hairline-legend', h
.infoDiv
).html(html
);
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
);
302 // Deletes a hairline and removes it from the chart.
303 hairlines
.prototype.removeHairline
= function(h
) {
304 var idx
= this.hairlines_
.indexOf(h
);
306 this.hairlines_
.splice(idx
, 1);
307 $([h
.lineDiv
, h
.infoDiv
]).remove();
309 Dygraph
.warn('Tried to remove non-existent hairline.');
313 hairlines
.prototype.didDrawChart
= function(e
) {
316 // Early out in the (common) case of zero hairlines.
317 if (this.hairlines_
.length
=== 0) return;
319 this.updateHairlineDivPositions();
320 this.attachHairlinesToChart_();
321 this.updateHairlineInfo();
322 this.updateHairlineStyles();
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
);
337 hairlines
.prototype.click
= function(e
) {
338 if (this.addTimer_
) {
339 // Another click is in progress; ignore this one.
343 var area
= e
.dygraph
.getArea();
344 var xval
= this.dygraph_
.toDataXCoord(e
.canvasx
);
347 this.addTimer_
= setTimeout(function() {
348 that
.addTimer_
= null;
349 that
.hairlines_
.push(that
.createHairline({xval
: xval
}));
351 that
.updateHairlineDivPositions();
352 that
.updateHairlineInfo();
353 that
.updateHairlineStyles();
354 that
.attachHairlinesToChart_();
356 $(that
).triggerHandler('hairlineCreated', {
359 $(that
).triggerHandler('hairlinesChanged', {});
363 hairlines
.prototype.dblclick
= function(e
) {
364 if (this.addTimer_
) {
365 clearTimeout(this.addTimer_
);
366 this.addTimer_
= null;
370 hairlines
.prototype.destroy
= function() {
378 * This is a restricted view of this.hairlines_ which doesn't expose
379 * implementation details like the handle divs.
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.
389 * @param {!Hairline} h Internal hairline.
390 * @return {!PublicHairline} Restricted public view of the hairline.
392 hairlines
.prototype.createPublicHairline_
= function(h
) {
395 interpolated
: h
.interpolated
,
401 * @return {!Array.<!PublicHairline>} The current set of hairlines, ordered
402 * from back to front.
404 hairlines
.prototype.get
= function() {
406 for (var i
= 0; i
< this.hairlines_
.length
; i
++) {
407 var h
= this.hairlines_
[i
];
408 result
.push(this.createPublicHairline_(h
));
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
418 * @param {!Array.<!PublicHairline>} hairlines The new set of hairlines,
419 * ordered from back to front.
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
];
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
;
433 this.hairlines_
.push(this.createHairline({
435 interpolated
: h
.interpolated
,
442 // If there are any remaining hairlines, destroy them.
443 while (hairlines
.length
< this.hairlines_
.length
) {
444 this.removeHairline(this.hairlines_
[hairlines
.length
]);
447 this.updateHairlineDivPositions();
448 this.updateHairlineInfo();
449 this.updateHairlineStyles();
451 this.attachHairlinesToChart_();
454 $(this).triggerHandler('hairlinesChanged', {});