X-Git-Url: https://adrianiainlam.tk/git/?a=blobdiff_plain;f=dygraph-interaction-model.js;h=2af345c7dcf7f4f7fe18d9871dc1db4900bc76af;hb=cd872296479aecce62b74c787dc778732070c498;hp=98d14110c1e14eafdf55b36713cac82a689b8a67;hpb=fbff6d71498de79d95df32c4c3005c0139c88788;p=dygraphs.git diff --git a/dygraph-interaction-model.js b/dygraph-interaction-model.js index 98d1411..2af345c 100644 --- a/dygraph-interaction-model.js +++ b/dygraph-interaction-model.js @@ -10,6 +10,9 @@ * @author Robert Konigsberg (konigsberg@google.com) */ +/*jshint globalstrict: true */ +/*global Dygraph:false */ +"use strict"; /** * A collection of functions to facilitate build custom interaction models. @@ -25,12 +28,14 @@ Dygraph.Interaction = {}; * Custom interaction model builders can use it to provide the default * panning behavior. * - * @param { Event } event the event object which led to the startPan call. - * @param { Dygraph} g The dygraph on which to act. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the startPan call. + * @param {Dygraph} g The dygraph on which to act. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.startPan = function(event, g, context) { + var i, axis; context.isPanning = true; var xRange = g.xAxisRange(); context.dateRange = xRange[1] - xRange[0]; @@ -51,15 +56,15 @@ Dygraph.Interaction.startPan = function(event, g, context) { var boundedValues = []; var maxYPixelsToDraw = g.height_ * g.attr_("panEdgeFraction"); - for (var i = 0; i < g.axes_.length; i++) { - var axis = g.axes_[i]; + for (i = 0; i < g.axes_.length; i++) { + axis = g.axes_[i]; var yExtremes = axis.extremeRange; var boundedTopY = g.toDomYCoord(yExtremes[0], i) + maxYPixelsToDraw; var boundedBottomY = g.toDomYCoord(yExtremes[1], i) - maxYPixelsToDraw; - var boundedTopValue = g.toDataYCoord(boundedTopY); - var boundedBottomValue = g.toDataYCoord(boundedBottomY); + var boundedTopValue = g.toDataYCoord(boundedTopY, i); + var boundedBottomValue = g.toDataYCoord(boundedBottomY, i); boundedValues[i] = [boundedTopValue, boundedBottomValue]; } @@ -68,20 +73,27 @@ Dygraph.Interaction.startPan = function(event, g, context) { // Record the range of each y-axis at the start of the drag. // If any axis has a valueRange or valueWindow, then we want a 2D pan. + // We can't store data directly in g.axes_, because it does not belong to us + // and could change out from under us during a pan (say if there's a data + // update). context.is2DPan = false; - for (var i = 0; i < g.axes_.length; i++) { - var axis = g.axes_[i]; + context.axes = []; + for (i = 0; i < g.axes_.length; i++) { + axis = g.axes_[i]; + var axis_data = {}; var yRange = g.yAxisRange(i); // TODO(konigsberg): These values should be in |context|. // In log scale, initialTopValue, dragValueRange and unitsPerPixel are log scale. - if (axis.logscale) { - axis.initialTopValue = Dygraph.log10(yRange[1]); - axis.dragValueRange = Dygraph.log10(yRange[1]) - Dygraph.log10(yRange[0]); + var logscale = g.attributes_.getForAxis("logscale", i); + if (logscale) { + axis_data.initialTopValue = Dygraph.log10(yRange[1]); + axis_data.dragValueRange = Dygraph.log10(yRange[1]) - Dygraph.log10(yRange[0]); } else { - axis.initialTopValue = yRange[1]; - axis.dragValueRange = yRange[1] - yRange[0]; + axis_data.initialTopValue = yRange[1]; + axis_data.dragValueRange = yRange[1] - yRange[0]; } - axis.unitsPerPixel = axis.dragValueRange / (g.plotter_.area.h - 1); + axis_data.unitsPerPixel = axis_data.dragValueRange / (g.plotter_.area.h - 1); + context.axes.push(axis_data); // While calculating axes, set 2dpan. if (axis.valueWindow || axis.valueRange) context.is2DPan = true; @@ -96,10 +108,11 @@ Dygraph.Interaction.startPan = function(event, g, context) { * Custom interaction model builders can use it to provide the default * panning behavior. * - * @param { Event } event the event object which led to the movePan call. - * @param { Dygraph} g The dygraph on which to act. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the movePan call. + * @param {Dygraph} g The dygraph on which to act. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.movePan = function(event, g, context) { context.dragEndX = g.dragGetX_(event, context); @@ -123,29 +136,32 @@ Dygraph.Interaction.movePan = function(event, g, context) { // y-axis scaling is automatic unless this is a full 2D pan. if (context.is2DPan) { + + var pixelsDragged = context.dragEndY - context.dragStartY; + // Adjust each axis appropriately. for (var i = 0; i < g.axes_.length; i++) { var axis = g.axes_[i]; - - var pixelsDragged = context.dragEndY - context.dragStartY; - var unitsDragged = pixelsDragged * axis.unitsPerPixel; + var axis_data = context.axes[i]; + var unitsDragged = pixelsDragged * axis_data.unitsPerPixel; var boundedValue = context.boundedValues ? context.boundedValues[i] : null; // In log scale, maxValue and minValue are the logs of those values. - var maxValue = axis.initialTopValue + unitsDragged; + var maxValue = axis_data.initialTopValue + unitsDragged; if (boundedValue) { maxValue = Math.min(maxValue, boundedValue[1]); } - var minValue = maxValue - axis.dragValueRange; + var minValue = maxValue - axis_data.dragValueRange; if (boundedValue) { if (minValue < boundedValue[0]) { // Adjust maxValue, and recompute minValue. maxValue = maxValue - (minValue - boundedValue[0]); - minValue = maxValue - axis.dragValueRange; + minValue = maxValue - axis_data.dragValueRange; } } - if (axis.logscale) { + var logscale = g.attributes_.getForAxis("logscale", i); + if (logscale) { axis.valueWindow = [ Math.pow(Dygraph.LOG_SCALE, minValue), Math.pow(Dygraph.LOG_SCALE, maxValue) ]; } else { @@ -165,10 +181,11 @@ Dygraph.Interaction.movePan = function(event, g, context) { * Custom interaction model builders can use it to provide the default * panning behavior. * - * @param { Event } event the event object which led to the startZoom call. - * @param { Dygraph} g The dygraph on which to act. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the endPan call. + * @param {Dygraph} g The dygraph on which to act. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.endPan = function(event, g, context) { context.dragEndX = g.dragGetX_(event, context); @@ -178,12 +195,10 @@ Dygraph.Interaction.endPan = function(event, g, context) { var regionHeight = Math.abs(context.dragEndY - context.dragStartY); if (regionWidth < 2 && regionHeight < 2 && - g.lastx_ != undefined && g.lastx_ != -1) { + g.lastx_ !== undefined && g.lastx_ != -1) { Dygraph.Interaction.treatMouseOpAsClick(g, event, context); } - // TODO(konigsberg): Clear the context data from the axis. - // (replace with "context = {}" ?) // TODO(konigsberg): mouseup should just delete the // context object, and mousedown should create a new one. context.isPanning = false; @@ -193,6 +208,7 @@ Dygraph.Interaction.endPan = function(event, g, context) { context.valueRange = null; context.boundedDates = null; context.boundedValues = null; + context.axes = null; }; /** @@ -203,13 +219,15 @@ Dygraph.Interaction.endPan = function(event, g, context) { * Custom interaction model builders can use it to provide the default * zooming behavior. * - * @param { Event } event the event object which led to the startZoom call. - * @param { Dygraph} g The dygraph on which to act. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the startZoom call. + * @param {Dygraph} g The dygraph on which to act. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.startZoom = function(event, g, context) { context.isZooming = true; + context.zoomMoved = false; }; /** @@ -220,12 +238,14 @@ Dygraph.Interaction.startZoom = function(event, g, context) { * Custom interaction model builders can use it to provide the default * zooming behavior. * - * @param { Event } event the event object which led to the moveZoom call. - * @param { Dygraph} g The dygraph on which to act. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the moveZoom call. + * @param {Dygraph} g The dygraph on which to act. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.moveZoom = function(event, g, context) { + context.zoomMoved = true; context.dragEndX = g.dragGetX_(event, context); context.dragEndY = g.dragGetY_(event, context); @@ -250,13 +270,19 @@ Dygraph.Interaction.moveZoom = function(event, g, context) { context.prevDragDirection = context.dragDirection; }; +/** + * @param {Dygraph} g + * @param {Event} event + * @param {Object} context + */ Dygraph.Interaction.treatMouseOpAsClick = function(g, event, context) { var clickCallback = g.attr_('clickCallback'); var pointClickCallback = g.attr_('pointClickCallback'); var selectedPoint = null; - // Find out if the click occurs on a point. This only matters if there's a pointClickCallback. + // Find out if the click occurs on a point. This only matters if there's a + // pointClickCallback. if (pointClickCallback) { var closestIdx = -1; var closestDistance = Number.MAX_VALUE; @@ -299,10 +325,11 @@ Dygraph.Interaction.treatMouseOpAsClick = function(g, event, context) { * Custom interaction model builders can use it to provide the default * zooming behavior. * - * @param { Event } event the event object which led to the endZoom call. - * @param { Dygraph} g The dygraph on which to end the zoom. - * @param { Object} context The dragging context object (with - * dragStartX/dragStartY/etc. properties). This function modifies the context. + * @param {Event} event the event object which led to the endZoom call. + * @param {Dygraph} g The dygraph on which to end the zoom. + * @param {Object} context The dragging context object (with + * dragStartX/dragStartY/etc. properties). This function modifies the + * context. */ Dygraph.Interaction.endZoom = function(event, g, context) { context.isZooming = false; @@ -312,24 +339,223 @@ Dygraph.Interaction.endZoom = function(event, g, context) { var regionHeight = Math.abs(context.dragEndY - context.dragStartY); if (regionWidth < 2 && regionHeight < 2 && - g.lastx_ != undefined && g.lastx_ != -1) { + g.lastx_ !== undefined && g.lastx_ != -1) { Dygraph.Interaction.treatMouseOpAsClick(g, event, context); } + // The zoom rectangle is visibly clipped to the plot area, so its behavior + // should be as well. + // See http://code.google.com/p/dygraphs/issues/detail?id=280 + var plotArea = g.getArea(); if (regionWidth >= 10 && context.dragDirection == Dygraph.HORIZONTAL) { - g.doZoomX_(Math.min(context.dragStartX, context.dragEndX), - Math.max(context.dragStartX, context.dragEndX)); + var left = Math.min(context.dragStartX, context.dragEndX), + right = Math.max(context.dragStartX, context.dragEndX); + left = Math.max(left, plotArea.x); + right = Math.min(right, plotArea.x + plotArea.w); + if (left < right) { + g.doZoomX_(left, right); + } + context.cancelNextDblclick = true; } else if (regionHeight >= 10 && context.dragDirection == Dygraph.VERTICAL) { - g.doZoomY_(Math.min(context.dragStartY, context.dragEndY), - Math.max(context.dragStartY, context.dragEndY)); + var top = Math.min(context.dragStartY, context.dragEndY), + bottom = Math.max(context.dragStartY, context.dragEndY); + top = Math.max(top, plotArea.y); + bottom = Math.min(bottom, plotArea.y + plotArea.h); + if (top < bottom) { + g.doZoomY_(top, bottom); + } + context.cancelNextDblclick = true; } else { - g.clearZoomRect_(); + if (context.zoomMoved) g.clearZoomRect_(); } context.dragStartX = null; context.dragStartY = null; }; /** + * @private + */ +Dygraph.Interaction.startTouch = function(event, g, context) { + event.preventDefault(); // touch browsers are all nice. + if (event.touches.length > 1) { + // If the user ever puts two fingers down, it's not a double tap. + context.startTimeForDoubleTapMs = null; + } + + var touches = []; + for (var i = 0; i < event.touches.length; i++) { + var t = event.touches[i]; + // we dispense with 'dragGetX_' because all touchBrowsers support pageX + touches.push({ + pageX: t.pageX, + pageY: t.pageY, + dataX: g.toDataXCoord(t.pageX), + dataY: g.toDataYCoord(t.pageY) + // identifier: t.identifier + }); + } + context.initialTouches = touches; + + if (touches.length == 1) { + // This is just a swipe. + context.initialPinchCenter = touches[0]; + context.touchDirections = { x: true, y: true }; + } else if (touches.length >= 2) { + // It's become a pinch! + // In case there are 3+ touches, we ignore all but the "first" two. + + // only screen coordinates can be averaged (data coords could be log scale). + context.initialPinchCenter = { + pageX: 0.5 * (touches[0].pageX + touches[1].pageX), + pageY: 0.5 * (touches[0].pageY + touches[1].pageY), + + // TODO(danvk): remove + dataX: 0.5 * (touches[0].dataX + touches[1].dataX), + dataY: 0.5 * (touches[0].dataY + touches[1].dataY) + }; + + // Make pinches in a 45-degree swath around either axis 1-dimensional zooms. + var initialAngle = 180 / Math.PI * Math.atan2( + context.initialPinchCenter.pageY - touches[0].pageY, + touches[0].pageX - context.initialPinchCenter.pageX); + + // use symmetry to get it into the first quadrant. + initialAngle = Math.abs(initialAngle); + if (initialAngle > 90) initialAngle = 90 - initialAngle; + + context.touchDirections = { + x: (initialAngle < (90 - 45/2)), + y: (initialAngle > 45/2) + }; + } + + // save the full x & y ranges. + context.initialRange = { + x: g.xAxisRange(), + y: g.yAxisRange() + }; +}; + +/** + * @private + */ +Dygraph.Interaction.moveTouch = function(event, g, context) { + // If the tap moves, then it's definitely not part of a double-tap. + context.startTimeForDoubleTapMs = null; + + var i, touches = []; + for (i = 0; i < event.touches.length; i++) { + var t = event.touches[i]; + touches.push({ + pageX: t.pageX, + pageY: t.pageY + }); + } + var initialTouches = context.initialTouches; + + var c_now; + + // old and new centers. + var c_init = context.initialPinchCenter; + if (touches.length == 1) { + c_now = touches[0]; + } else { + c_now = { + pageX: 0.5 * (touches[0].pageX + touches[1].pageX), + pageY: 0.5 * (touches[0].pageY + touches[1].pageY) + }; + } + + // this is the "swipe" component + // we toss it out for now, but could use it in the future. + var swipe = { + pageX: c_now.pageX - c_init.pageX, + pageY: c_now.pageY - c_init.pageY + }; + var dataWidth = context.initialRange.x[1] - context.initialRange.x[0]; + var dataHeight = context.initialRange.y[0] - context.initialRange.y[1]; + swipe.dataX = (swipe.pageX / g.plotter_.area.w) * dataWidth; + swipe.dataY = (swipe.pageY / g.plotter_.area.h) * dataHeight; + var xScale, yScale; + + // The residual bits are usually split into scale & rotate bits, but we split + // them into x-scale and y-scale bits. + if (touches.length == 1) { + xScale = 1.0; + yScale = 1.0; + } else if (touches.length >= 2) { + var initHalfWidth = (initialTouches[1].pageX - c_init.pageX); + xScale = (touches[1].pageX - c_now.pageX) / initHalfWidth; + + var initHalfHeight = (initialTouches[1].pageY - c_init.pageY); + yScale = (touches[1].pageY - c_now.pageY) / initHalfHeight; + } + + // Clip scaling to [1/8, 8] to prevent too much blowup. + xScale = Math.min(8, Math.max(0.125, xScale)); + yScale = Math.min(8, Math.max(0.125, yScale)); + + var didZoom = false; + if (context.touchDirections.x) { + g.dateWindow_ = [ + c_init.dataX - swipe.dataX + (context.initialRange.x[0] - c_init.dataX) / xScale, + c_init.dataX - swipe.dataX + (context.initialRange.x[1] - c_init.dataX) / xScale + ]; + didZoom = true; + } + + if (context.touchDirections.y) { + for (i = 0; i < 1 /*g.axes_.length*/; i++) { + var axis = g.axes_[i]; + var logscale = g.attributes_.getForAxis("logscale", i); + if (logscale) { + // TODO(danvk): implement + } else { + axis.valueWindow = [ + c_init.dataY - swipe.dataY + (context.initialRange.y[0] - c_init.dataY) / yScale, + c_init.dataY - swipe.dataY + (context.initialRange.y[1] - c_init.dataY) / yScale + ]; + didZoom = true; + } + } + } + + g.drawGraph_(false); + + // We only call zoomCallback on zooms, not pans, to mirror desktop behavior. + if (didZoom && touches.length > 1 && g.attr_('zoomCallback')) { + var viewWindow = g.xAxisRange(); + g.attr_("zoomCallback")(viewWindow[0], viewWindow[1], g.yAxisRanges()); + } +}; + +/** + * @private + */ +Dygraph.Interaction.endTouch = function(event, g, context) { + if (event.touches.length !== 0) { + // this is effectively a "reset" + Dygraph.Interaction.startTouch(event, g, context); + } else if (event.changedTouches.length == 1) { + // Could be part of a "double tap" + // The heuristic here is that it's a double-tap if the two touchend events + // occur within 500ms and within a 50x50 pixel box. + var now = new Date().getTime(); + var t = event.changedTouches[0]; + if (context.startTimeForDoubleTapMs && + now - context.startTimeForDoubleTapMs < 500 && + context.doubleTapX && Math.abs(context.doubleTapX - t.screenX) < 50 && + context.doubleTapY && Math.abs(context.doubleTapY - t.screenY) < 50) { + g.resetZoom(); + } else { + context.startTimeForDoubleTapMs = now; + context.doubleTapX = t.screenX; + context.doubleTapY = t.screenY; + } + } +}; + +/** * Default interation model for dygraphs. You can refer to specific elements of * this when constructing your own interaction model, e.g.: * g.updateOptions( { @@ -341,6 +567,9 @@ Dygraph.Interaction.endZoom = function(event, g, context) { Dygraph.Interaction.defaultModel = { // Track the beginning of drag events mousedown: function(event, g, context) { + // Right-click should not initiate a zoom. + if (event.button && event.button == 2) return; + context.initializeMouseDown(event, g, context); if (event.altKey || event.shiftKey) { @@ -367,22 +596,35 @@ Dygraph.Interaction.defaultModel = { } }, + touchstart: function(event, g, context) { + Dygraph.Interaction.startTouch(event, g, context); + }, + touchmove: function(event, g, context) { + Dygraph.Interaction.moveTouch(event, g, context); + }, + touchend: function(event, g, context) { + Dygraph.Interaction.endTouch(event, g, context); + }, + // Temporarily cancel the dragging event when the mouse leaves the graph mouseout: function(event, g, context) { if (context.isZooming) { context.dragEndX = null; context.dragEndY = null; + g.clearZoomRect_(); } }, // Disable zooming out if panning. dblclick: function(event, g, context) { + if (context.cancelNextDblclick) { + context.cancelNextDblclick = false; + return; + } if (event.altKey || event.shiftKey) { return; } - // TODO(konigsberg): replace g.doUnzoom()_ with something that is - // friendlier to public use. - g.doUnzoom_(); + g.resetZoom(); } }; @@ -409,8 +651,26 @@ Dygraph.Interaction.nonInteractiveModel_ = { var regionHeight = Math.abs(context.dragEndY - context.dragStartY); if (regionWidth < 2 && regionHeight < 2 && - g.lastx_ != undefined && g.lastx_ != -1) { + g.lastx_ !== undefined && g.lastx_ != -1) { Dygraph.Interaction.treatMouseOpAsClick(g, event, context); } } }; + +// Default interaction model when using the range selector. +Dygraph.Interaction.dragIsPanInteractionModel = { + mousedown: function(event, g, context) { + context.initializeMouseDown(event, g, context); + Dygraph.startPan(event, g, context); + }, + mousemove: function(event, g, context) { + if (context.isPanning) { + Dygraph.movePan(event, g, context); + } + }, + mouseup: function(event, g, context) { + if (context.isPanning) { + Dygraph.endPan(event, g, context); + } + } +};