Merge pull request #591 from danvk/container
[dygraphs.git] / src / extras / hairlines.js
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 })();