Merge branch 'master' of https://github.com/danvk/dygraphs
[dygraphs.git] / dygraph-interaction-model.js
... / ...
CommitLineData
1/**
2 * @license
3 * Copyright 2011 Robert Konigsberg (konigsberg@google.com)
4 * MIT-licensed (http://opensource.org/licenses/MIT)
5 */
6
7/**
8 * @fileoverview The default interaction model for Dygraphs. This is kept out
9 * of dygraph.js for better navigability.
10 * @author Robert Konigsberg (konigsberg@google.com)
11 */
12
13/*jshint globalstrict: true */
14/*global Dygraph:false */
15"use strict";
16
17/**
18 * A collection of functions to facilitate build custom interaction models.
19 * @class
20 */
21Dygraph.Interaction = {};
22
23/**
24 * Called in response to an interaction model operation that
25 * should start the default panning behavior.
26 *
27 * It's used in the default callback for "mousedown" operations.
28 * Custom interaction model builders can use it to provide the default
29 * panning behavior.
30 *
31 * @param {Event} event the event object which led to the startPan call.
32 * @param {Dygraph} g The dygraph on which to act.
33 * @param {Object} context The dragging context object (with
34 * dragStartX/dragStartY/etc. properties). This function modifies the
35 * context.
36 */
37Dygraph.Interaction.startPan = function(event, g, context) {
38 var i, axis;
39 context.isPanning = true;
40 var xRange = g.xAxisRange();
41 context.dateRange = xRange[1] - xRange[0];
42 context.initialLeftmostDate = xRange[0];
43 context.xUnitsPerPixel = context.dateRange / (g.plotter_.area.w - 1);
44
45 if (g.attr_("panEdgeFraction")) {
46 var maxXPixelsToDraw = g.width_ * g.attr_("panEdgeFraction");
47 var xExtremes = g.xAxisExtremes(); // I REALLY WANT TO CALL THIS xTremes!
48
49 var boundedLeftX = g.toDomXCoord(xExtremes[0]) - maxXPixelsToDraw;
50 var boundedRightX = g.toDomXCoord(xExtremes[1]) + maxXPixelsToDraw;
51
52 var boundedLeftDate = g.toDataXCoord(boundedLeftX);
53 var boundedRightDate = g.toDataXCoord(boundedRightX);
54 context.boundedDates = [boundedLeftDate, boundedRightDate];
55
56 var boundedValues = [];
57 var maxYPixelsToDraw = g.height_ * g.attr_("panEdgeFraction");
58
59 for (i = 0; i < g.axes_.length; i++) {
60 axis = g.axes_[i];
61 var yExtremes = axis.extremeRange;
62
63 var boundedTopY = g.toDomYCoord(yExtremes[0], i) + maxYPixelsToDraw;
64 var boundedBottomY = g.toDomYCoord(yExtremes[1], i) - maxYPixelsToDraw;
65
66 var boundedTopValue = g.toDataYCoord(boundedTopY);
67 var boundedBottomValue = g.toDataYCoord(boundedBottomY);
68
69 boundedValues[i] = [boundedTopValue, boundedBottomValue];
70 }
71 context.boundedValues = boundedValues;
72 }
73
74 // Record the range of each y-axis at the start of the drag.
75 // If any axis has a valueRange or valueWindow, then we want a 2D pan.
76 // We can't store data directly in g.axes_, because it does not belong to us
77 // and could change out from under us during a pan (say if there's a data
78 // update).
79 context.is2DPan = false;
80 context.axes = [];
81 for (i = 0; i < g.axes_.length; i++) {
82 axis = g.axes_[i];
83 var axis_data = {};
84 var yRange = g.yAxisRange(i);
85 // TODO(konigsberg): These values should be in |context|.
86 // In log scale, initialTopValue, dragValueRange and unitsPerPixel are log scale.
87 if (axis.logscale) {
88 axis_data.initialTopValue = Dygraph.log10(yRange[1]);
89 axis_data.dragValueRange = Dygraph.log10(yRange[1]) - Dygraph.log10(yRange[0]);
90 } else {
91 axis_data.initialTopValue = yRange[1];
92 axis_data.dragValueRange = yRange[1] - yRange[0];
93 }
94 axis_data.unitsPerPixel = axis_data.dragValueRange / (g.plotter_.area.h - 1);
95 context.axes.push(axis_data);
96
97 // While calculating axes, set 2dpan.
98 if (axis.valueWindow || axis.valueRange) context.is2DPan = true;
99 }
100};
101
102/**
103 * Called in response to an interaction model operation that
104 * responds to an event that pans the view.
105 *
106 * It's used in the default callback for "mousemove" operations.
107 * Custom interaction model builders can use it to provide the default
108 * panning behavior.
109 *
110 * @param {Event} event the event object which led to the movePan call.
111 * @param {Dygraph} g The dygraph on which to act.
112 * @param {Object} context The dragging context object (with
113 * dragStartX/dragStartY/etc. properties). This function modifies the
114 * context.
115 */
116Dygraph.Interaction.movePan = function(event, g, context) {
117 context.dragEndX = g.dragGetX_(event, context);
118 context.dragEndY = g.dragGetY_(event, context);
119
120 var minDate = context.initialLeftmostDate -
121 (context.dragEndX - context.dragStartX) * context.xUnitsPerPixel;
122 if (context.boundedDates) {
123 minDate = Math.max(minDate, context.boundedDates[0]);
124 }
125 var maxDate = minDate + context.dateRange;
126 if (context.boundedDates) {
127 if (maxDate > context.boundedDates[1]) {
128 // Adjust minDate, and recompute maxDate.
129 minDate = minDate - (maxDate - context.boundedDates[1]);
130 maxDate = minDate + context.dateRange;
131 }
132 }
133
134 g.dateWindow_ = [minDate, maxDate];
135
136 // y-axis scaling is automatic unless this is a full 2D pan.
137 if (context.is2DPan) {
138 // Adjust each axis appropriately.
139 for (var i = 0; i < g.axes_.length; i++) {
140 var axis = g.axes_[i];
141 var axis_data = context.axes[i];
142
143 var pixelsDragged = context.dragEndY - context.dragStartY;
144 var unitsDragged = pixelsDragged * axis_data.unitsPerPixel;
145
146 var boundedValue = context.boundedValues ? context.boundedValues[i] : null;
147
148 // In log scale, maxValue and minValue are the logs of those values.
149 var maxValue = axis_data.initialTopValue + unitsDragged;
150 if (boundedValue) {
151 maxValue = Math.min(maxValue, boundedValue[1]);
152 }
153 var minValue = maxValue - axis_data.dragValueRange;
154 if (boundedValue) {
155 if (minValue < boundedValue[0]) {
156 // Adjust maxValue, and recompute minValue.
157 maxValue = maxValue - (minValue - boundedValue[0]);
158 minValue = maxValue - axis_data.dragValueRange;
159 }
160 }
161 if (axis.logscale) {
162 axis.valueWindow = [ Math.pow(Dygraph.LOG_SCALE, minValue),
163 Math.pow(Dygraph.LOG_SCALE, maxValue) ];
164 } else {
165 axis.valueWindow = [ minValue, maxValue ];
166 }
167 }
168 }
169
170 g.drawGraph_(false);
171};
172
173/**
174 * Called in response to an interaction model operation that
175 * responds to an event that ends panning.
176 *
177 * It's used in the default callback for "mouseup" operations.
178 * Custom interaction model builders can use it to provide the default
179 * panning behavior.
180 *
181 * @param {Event} event the event object which led to the endPan call.
182 * @param {Dygraph} g The dygraph on which to act.
183 * @param {Object} context The dragging context object (with
184 * dragStartX/dragStartY/etc. properties). This function modifies the
185 * context.
186 */
187Dygraph.Interaction.endPan = function(event, g, context) {
188 context.dragEndX = g.dragGetX_(event, context);
189 context.dragEndY = g.dragGetY_(event, context);
190
191 var regionWidth = Math.abs(context.dragEndX - context.dragStartX);
192 var regionHeight = Math.abs(context.dragEndY - context.dragStartY);
193
194 if (regionWidth < 2 && regionHeight < 2 &&
195 g.lastx_ !== undefined && g.lastx_ != -1) {
196 Dygraph.Interaction.treatMouseOpAsClick(g, event, context);
197 }
198
199 // TODO(konigsberg): mouseup should just delete the
200 // context object, and mousedown should create a new one.
201 context.isPanning = false;
202 context.is2DPan = false;
203 context.initialLeftmostDate = null;
204 context.dateRange = null;
205 context.valueRange = null;
206 context.boundedDates = null;
207 context.boundedValues = null;
208 context.axes = null;
209};
210
211/**
212 * Called in response to an interaction model operation that
213 * responds to an event that starts zooming.
214 *
215 * It's used in the default callback for "mousedown" operations.
216 * Custom interaction model builders can use it to provide the default
217 * zooming behavior.
218 *
219 * @param {Event} event the event object which led to the startZoom call.
220 * @param {Dygraph} g The dygraph on which to act.
221 * @param {Object} context The dragging context object (with
222 * dragStartX/dragStartY/etc. properties). This function modifies the
223 * context.
224 */
225Dygraph.Interaction.startZoom = function(event, g, context) {
226 context.isZooming = true;
227 context.zoomMoved = false;
228};
229
230/**
231 * Called in response to an interaction model operation that
232 * responds to an event that defines zoom boundaries.
233 *
234 * It's used in the default callback for "mousemove" operations.
235 * Custom interaction model builders can use it to provide the default
236 * zooming behavior.
237 *
238 * @param {Event} event the event object which led to the moveZoom call.
239 * @param {Dygraph} g The dygraph on which to act.
240 * @param {Object} context The dragging context object (with
241 * dragStartX/dragStartY/etc. properties). This function modifies the
242 * context.
243 */
244Dygraph.Interaction.moveZoom = function(event, g, context) {
245 context.zoomMoved = true;
246 context.dragEndX = g.dragGetX_(event, context);
247 context.dragEndY = g.dragGetY_(event, context);
248
249 var xDelta = Math.abs(context.dragStartX - context.dragEndX);
250 var yDelta = Math.abs(context.dragStartY - context.dragEndY);
251
252 // drag direction threshold for y axis is twice as large as x axis
253 context.dragDirection = (xDelta < yDelta / 2) ? Dygraph.VERTICAL : Dygraph.HORIZONTAL;
254
255 g.drawZoomRect_(
256 context.dragDirection,
257 context.dragStartX,
258 context.dragEndX,
259 context.dragStartY,
260 context.dragEndY,
261 context.prevDragDirection,
262 context.prevEndX,
263 context.prevEndY);
264
265 context.prevEndX = context.dragEndX;
266 context.prevEndY = context.dragEndY;
267 context.prevDragDirection = context.dragDirection;
268};
269
270/**
271 * @param {Dygraph} g
272 * @param {Event} event
273 * @param {Object} context
274 */
275Dygraph.Interaction.treatMouseOpAsClick = function(g, event, context) {
276 var clickCallback = g.attr_('clickCallback');
277 var pointClickCallback = g.attr_('pointClickCallback');
278
279 var selectedPoint = null;
280
281 // Find out if the click occurs on a point. This only matters if there's a
282 // pointClickCallback.
283 if (pointClickCallback) {
284 var closestIdx = -1;
285 var closestDistance = Number.MAX_VALUE;
286
287 // check if the click was on a particular point.
288 for (var i = 0; i < g.selPoints_.length; i++) {
289 var p = g.selPoints_[i];
290 var distance = Math.pow(p.canvasx - context.dragEndX, 2) +
291 Math.pow(p.canvasy - context.dragEndY, 2);
292 if (!isNaN(distance) &&
293 (closestIdx == -1 || distance < closestDistance)) {
294 closestDistance = distance;
295 closestIdx = i;
296 }
297 }
298
299 // Allow any click within two pixels of the dot.
300 var radius = g.attr_('highlightCircleSize') + 2;
301 if (closestDistance <= radius * radius) {
302 selectedPoint = g.selPoints_[closestIdx];
303 }
304 }
305
306 if (selectedPoint) {
307 pointClickCallback(event, selectedPoint);
308 }
309
310 // TODO(danvk): pass along more info about the points, e.g. 'x'
311 if (clickCallback) {
312 clickCallback(event, g.lastx_, g.selPoints_);
313 }
314};
315
316/**
317 * Called in response to an interaction model operation that
318 * responds to an event that performs a zoom based on previously defined
319 * bounds..
320 *
321 * It's used in the default callback for "mouseup" operations.
322 * Custom interaction model builders can use it to provide the default
323 * zooming behavior.
324 *
325 * @param {Event} event the event object which led to the endZoom call.
326 * @param {Dygraph} g The dygraph on which to end the zoom.
327 * @param {Object} context The dragging context object (with
328 * dragStartX/dragStartY/etc. properties). This function modifies the
329 * context.
330 */
331Dygraph.Interaction.endZoom = function(event, g, context) {
332 context.isZooming = false;
333 context.dragEndX = g.dragGetX_(event, context);
334 context.dragEndY = g.dragGetY_(event, context);
335 var regionWidth = Math.abs(context.dragEndX - context.dragStartX);
336 var regionHeight = Math.abs(context.dragEndY - context.dragStartY);
337
338 if (regionWidth < 2 && regionHeight < 2 &&
339 g.lastx_ !== undefined && g.lastx_ != -1) {
340 Dygraph.Interaction.treatMouseOpAsClick(g, event, context);
341 }
342
343 if (regionWidth >= 10 && context.dragDirection == Dygraph.HORIZONTAL) {
344 g.doZoomX_(Math.min(context.dragStartX, context.dragEndX),
345 Math.max(context.dragStartX, context.dragEndX));
346 context.cancelNextDblclick = true;
347 } else if (regionHeight >= 10 && context.dragDirection == Dygraph.VERTICAL) {
348 g.doZoomY_(Math.min(context.dragStartY, context.dragEndY),
349 Math.max(context.dragStartY, context.dragEndY));
350 context.cancelNextDblclick = true;
351 } else {
352 if (context.zoomMoved) g.clearZoomRect_();
353 }
354 context.dragStartX = null;
355 context.dragStartY = null;
356};
357
358/**
359 * @private
360 */
361Dygraph.Interaction.startTouch = function(event, g, context) {
362 event.preventDefault(); // touch browsers are all nice.
363 var touches = [];
364 for (var i = 0; i < event.touches.length; i++) {
365 var t = event.touches[i];
366 // we dispense with 'dragGetX_' because all touchBrowsers support pageX
367 touches.push({
368 pageX: t.pageX,
369 pageY: t.pageY,
370 dataX: g.toDataXCoord(t.pageX),
371 dataY: g.toDataYCoord(t.pageY)
372 // identifier: t.identifier
373 });
374 }
375 context.initialTouches = touches;
376
377 if (touches.length == 1) {
378 // This is just a swipe.
379 context.initialPinchCenter = touches[0];
380 context.touchDirections = { x: true, y: true };
381 } else if (touches.length == 2) {
382 // It's become a pinch!
383
384 // only screen coordinates can be averaged (data coords could be log scale).
385 context.initialPinchCenter = {
386 pageX: 0.5 * (touches[0].pageX + touches[1].pageX),
387 pageY: 0.5 * (touches[0].pageY + touches[1].pageY),
388
389 // TODO(danvk): remove
390 dataX: 0.5 * (touches[0].dataX + touches[1].dataX),
391 dataY: 0.5 * (touches[0].dataY + touches[1].dataY)
392 };
393
394 // Make pinches in a 45-degree swath around either axis 1-dimensional zooms.
395 var initialAngle = 180 / Math.PI * Math.atan2(
396 context.initialPinchCenter.pageY - touches[0].pageY,
397 touches[0].pageX - context.initialPinchCenter.pageX);
398
399 // use symmetry to get it into the first quadrant.
400 initialAngle = Math.abs(initialAngle);
401 if (initialAngle > 90) initialAngle = 90 - initialAngle;
402
403 context.touchDirections = {
404 x: (initialAngle < (90 - 45/2)),
405 y: (initialAngle > 45/2)
406 };
407 }
408
409 // save the full x & y ranges.
410 context.initialRange = {
411 x: g.xAxisRange(),
412 y: g.yAxisRange()
413 };
414};
415
416/**
417 * @private
418 */
419Dygraph.Interaction.moveTouch = function(event, g, context) {
420 var i, touches = [];
421 for (i = 0; i < event.touches.length; i++) {
422 var t = event.touches[i];
423 touches.push({
424 pageX: t.pageX,
425 pageY: t.pageY
426 });
427 }
428 var initialTouches = context.initialTouches;
429
430 var c_now;
431
432 // old and new centers.
433 var c_init = context.initialPinchCenter;
434 if (touches.length == 1) {
435 c_now = touches[0];
436 } else {
437 c_now = {
438 pageX: 0.5 * (touches[0].pageX + touches[1].pageX),
439 pageY: 0.5 * (touches[0].pageY + touches[1].pageY)
440 };
441 }
442
443 // this is the "swipe" component
444 // we toss it out for now, but could use it in the future.
445 var swipe = {
446 pageX: c_now.pageX - c_init.pageX,
447 pageY: c_now.pageY - c_init.pageY
448 };
449 var dataWidth = context.initialRange.x[1] - context.initialRange.x[0];
450 var dataHeight = context.initialRange.y[0] - context.initialRange.y[1];
451 swipe.dataX = (swipe.pageX / g.plotter_.area.w) * dataWidth;
452 swipe.dataY = (swipe.pageY / g.plotter_.area.h) * dataHeight;
453 var xScale, yScale;
454
455 // The residual bits are usually split into scale & rotate bits, but we split
456 // them into x-scale and y-scale bits.
457 if (touches.length == 1) {
458 xScale = 1.0;
459 yScale = 1.0;
460 } else if (touches.length == 2) {
461 var initHalfWidth = (initialTouches[1].pageX - c_init.pageX);
462 xScale = (touches[1].pageX - c_now.pageX) / initHalfWidth;
463
464 var initHalfHeight = (initialTouches[1].pageY - c_init.pageY);
465 yScale = (touches[1].pageY - c_now.pageY) / initHalfHeight;
466 }
467
468 // Clip scaling to [1/8, 8] to prevent too much blowup.
469 xScale = Math.min(8, Math.max(0.125, xScale));
470 yScale = Math.min(8, Math.max(0.125, yScale));
471
472 if (context.touchDirections.x) {
473 g.dateWindow_ = [
474 c_init.dataX - swipe.dataX + (context.initialRange.x[0] - c_init.dataX) / xScale,
475 c_init.dataX - swipe.dataX + (context.initialRange.x[1] - c_init.dataX) / xScale
476 ];
477 }
478
479 if (context.touchDirections.y) {
480 for (i = 0; i < 1 /*g.axes_.length*/; i++) {
481 var axis = g.axes_[i];
482 if (axis.logscale) {
483 // TODO(danvk): implement
484 } else {
485 axis.valueWindow = [
486 c_init.dataY - swipe.dataY + (context.initialRange.y[0] - c_init.dataY) / yScale,
487 c_init.dataY - swipe.dataY + (context.initialRange.y[1] - c_init.dataY) / yScale
488 ];
489 }
490 }
491 }
492
493 g.drawGraph_(false);
494};
495
496/**
497 * @private
498 */
499Dygraph.Interaction.endTouch = function(event, g, context) {
500 if (event.touches.length !== 0) {
501 // this is effectively a "reset"
502 Dygraph.Interaction.startTouch(event, g, context);
503 }
504};
505
506/**
507 * Default interation model for dygraphs. You can refer to specific elements of
508 * this when constructing your own interaction model, e.g.:
509 * g.updateOptions( {
510 * interactionModel: {
511 * mousedown: Dygraph.defaultInteractionModel.mousedown
512 * }
513 * } );
514 */
515Dygraph.Interaction.defaultModel = {
516 // Track the beginning of drag events
517 mousedown: function(event, g, context) {
518 // Right-click should not initiate a zoom.
519 if (event.button && event.button == 2) return;
520
521 context.initializeMouseDown(event, g, context);
522
523 if (event.altKey || event.shiftKey) {
524 Dygraph.startPan(event, g, context);
525 } else {
526 Dygraph.startZoom(event, g, context);
527 }
528 },
529
530 // Draw zoom rectangles when the mouse is down and the user moves around
531 mousemove: function(event, g, context) {
532 if (context.isZooming) {
533 Dygraph.moveZoom(event, g, context);
534 } else if (context.isPanning) {
535 Dygraph.movePan(event, g, context);
536 }
537 },
538
539 mouseup: function(event, g, context) {
540 if (context.isZooming) {
541 Dygraph.endZoom(event, g, context);
542 } else if (context.isPanning) {
543 Dygraph.endPan(event, g, context);
544 }
545 },
546
547 touchstart: function(event, g, context) {
548 Dygraph.Interaction.startTouch(event, g, context);
549 },
550 touchmove: function(event, g, context) {
551 Dygraph.Interaction.moveTouch(event, g, context);
552 },
553 touchend: function(event, g, context) {
554 Dygraph.Interaction.endTouch(event, g, context);
555 },
556
557 // Temporarily cancel the dragging event when the mouse leaves the graph
558 mouseout: function(event, g, context) {
559 if (context.isZooming) {
560 context.dragEndX = null;
561 context.dragEndY = null;
562 }
563 },
564
565 // Disable zooming out if panning.
566 dblclick: function(event, g, context) {
567 if (context.cancelNextDblclick) {
568 context.cancelNextDblclick = false;
569 return;
570 }
571 if (event.altKey || event.shiftKey) {
572 return;
573 }
574 // TODO(konigsberg): replace g.doUnzoom()_ with something that is
575 // friendlier to public use.
576 g.doUnzoom_();
577 }
578};
579
580Dygraph.DEFAULT_ATTRS.interactionModel = Dygraph.Interaction.defaultModel;
581
582// old ways of accessing these methods/properties
583Dygraph.defaultInteractionModel = Dygraph.Interaction.defaultModel;
584Dygraph.endZoom = Dygraph.Interaction.endZoom;
585Dygraph.moveZoom = Dygraph.Interaction.moveZoom;
586Dygraph.startZoom = Dygraph.Interaction.startZoom;
587Dygraph.endPan = Dygraph.Interaction.endPan;
588Dygraph.movePan = Dygraph.Interaction.movePan;
589Dygraph.startPan = Dygraph.Interaction.startPan;
590
591Dygraph.Interaction.nonInteractiveModel_ = {
592 mousedown: function(event, g, context) {
593 context.initializeMouseDown(event, g, context);
594 },
595 mouseup: function(event, g, context) {
596 // TODO(danvk): this logic is repeated in Dygraph.Interaction.endZoom
597 context.dragEndX = g.dragGetX_(event, context);
598 context.dragEndY = g.dragGetY_(event, context);
599 var regionWidth = Math.abs(context.dragEndX - context.dragStartX);
600 var regionHeight = Math.abs(context.dragEndY - context.dragStartY);
601
602 if (regionWidth < 2 && regionHeight < 2 &&
603 g.lastx_ !== undefined && g.lastx_ != -1) {
604 Dygraph.Interaction.treatMouseOpAsClick(g, event, context);
605 }
606 }
607};
608
609// Default interaction model when using the range selector.
610Dygraph.Interaction.dragIsPanInteractionModel = {
611 mousedown: function(event, g, context) {
612 context.initializeMouseDown(event, g, context);
613 Dygraph.startPan(event, g, context);
614 },
615 mousemove: function(event, g, context) {
616 if (context.isPanning) {
617 Dygraph.movePan(event, g, context);
618 }
619 },
620 mouseup: function(event, g, context) {
621 if (context.isPanning) {
622 Dygraph.endPan(event, g, context);
623 }
624 }
625};