TouchPointer.js 11.8 KB
/**
 * (c) 2010-2018 Torstein Honsi
 *
 * License: www.highcharts.com/license
 */

'use strict';

import H from './Globals.js';
import './Utilities.js';

var charts = H.charts,
    each = H.each,
    extend = H.extend,
    map = H.map,
    noop = H.noop,
    pick = H.pick,
    Pointer = H.Pointer;

// Support for touch devices
extend(Pointer.prototype, /** @lends Pointer.prototype */ {

    /**
     * Run translation operations
     *
     * @private
     * @function Highcharts.Pointer#pinchTranslate
     *
     * @param {Array<*>} pinchDown
     *
     * @param {Array<*>} touches
     *
     * @param {*} transform
     *
     * @param {*} selectionMarker
     *
     * @param {*} clip
     *
     * @param {*} lastValidTouch
     */
    pinchTranslate: function (
        pinchDown,
        touches,
        transform,
        selectionMarker,
        clip,
        lastValidTouch
    ) {
        if (this.zoomHor) {
            this.pinchTranslateDirection(
                true,
                pinchDown,
                touches,
                transform,
                selectionMarker,
                clip,
                lastValidTouch
            );
        }
        if (this.zoomVert) {
            this.pinchTranslateDirection(
                false,
                pinchDown,
                touches,
                transform,
                selectionMarker,
                clip,
                lastValidTouch
            );
        }
    },

    /**
     * Run translation operations for each direction (horizontal and vertical)
     * independently.
     *
     * @private
     * @function Highcharts.Pointer#pinchTranslateDirection
     *
     * @param {boolean} horiz
     *
     * @param {Array<*>} pinchDown
     *
     * @param {Array<*>} touches
     *
     * @param {*} transform
     *
     * @param {*} selectionMarker
     *
     * @param {*} clip
     *
     * @param {*} lastValidTouch
     *
     * @param {number|undefined} [forcedScale=1]
     */
    pinchTranslateDirection: function (horiz, pinchDown, touches, transform,
            selectionMarker, clip, lastValidTouch, forcedScale) {
        var chart = this.chart,
            xy = horiz ? 'x' : 'y',
            XY = horiz ? 'X' : 'Y',
            sChartXY = 'chart' + XY,
            wh = horiz ? 'width' : 'height',
            plotLeftTop = chart['plot' + (horiz ? 'Left' : 'Top')],
            selectionWH,
            selectionXY,
            clipXY,
            scale = forcedScale || 1,
            inverted = chart.inverted,
            bounds = chart.bounds[horiz ? 'h' : 'v'],
            singleTouch = pinchDown.length === 1,
            touch0Start = pinchDown[0][sChartXY],
            touch0Now = touches[0][sChartXY],
            touch1Start = !singleTouch && pinchDown[1][sChartXY],
            touch1Now = !singleTouch && touches[1][sChartXY],
            outOfBounds,
            transformScale,
            scaleKey,
            setScale = function () {
                // Don't zoom if fingers are too close on this axis
                if (!singleTouch && Math.abs(touch0Start - touch1Start) > 20) {
                    scale = forcedScale ||
                        Math.abs(touch0Now - touch1Now) /
                        Math.abs(touch0Start - touch1Start);
                }

                clipXY = ((plotLeftTop - touch0Now) / scale) + touch0Start;
                selectionWH = chart['plot' + (horiz ? 'Width' : 'Height')] /
                    scale;
            };

        // Set the scale, first pass
        setScale();

        // The clip position (x or y) is altered if out of bounds, the selection
        // position is not
        selectionXY = clipXY;

        // Out of bounds
        if (selectionXY < bounds.min) {
            selectionXY = bounds.min;
            outOfBounds = true;
        } else if (selectionXY + selectionWH > bounds.max) {
            selectionXY = bounds.max - selectionWH;
            outOfBounds = true;
        }

        // Is the chart dragged off its bounds, determined by dataMin and
        // dataMax?
        if (outOfBounds) {

            // Modify the touchNow position in order to create an elastic drag
            // movement. This indicates to the user that the chart is responsive
            // but can't be dragged further.
            touch0Now -= 0.8 * (touch0Now - lastValidTouch[xy][0]);
            if (!singleTouch) {
                touch1Now -= 0.8 * (touch1Now - lastValidTouch[xy][1]);
            }

            // Set the scale, second pass to adapt to the modified touchNow
            // positions
            setScale();

        } else {
            lastValidTouch[xy] = [touch0Now, touch1Now];
        }

        // Set geometry for clipping, selection and transformation
        if (!inverted) {
            clip[xy] = clipXY - plotLeftTop;
            clip[wh] = selectionWH;
        }
        scaleKey = inverted ? (horiz ? 'scaleY' : 'scaleX') : 'scale' + XY;
        transformScale = inverted ? 1 / scale : scale;

        selectionMarker[wh] = selectionWH;
        selectionMarker[xy] = selectionXY;
        transform[scaleKey] = scale;
        transform['translate' + XY] = (transformScale * plotLeftTop) +
            (touch0Now - (transformScale * touch0Start));
    },

    /**
     * Handle touch events with two touches
     *
     * @private
     * @function Highcharts.Pointer#pinch
     *
     * @param {Highcharts.PointerEvent} e
     */
    pinch: function (e) {

        var self = this,
            chart = self.chart,
            pinchDown = self.pinchDown,
            touches = e.touches,
            touchesLength = touches.length,
            lastValidTouch = self.lastValidTouch,
            hasZoom = self.hasZoom,
            selectionMarker = self.selectionMarker,
            transform = {},
            fireClickEvent = touchesLength === 1 && (
                (
                    self.inClass(e.target, 'highcharts-tracker') &&
                    chart.runTrackerClick
                ) ||
                self.runChartClick
            ),
            clip = {};

        // Don't initiate panning until the user has pinched. This prevents us
        // from blocking page scrolling as users scroll down a long page
        // (#4210).
        if (touchesLength > 1) {
            self.initiated = true;
        }

        // On touch devices, only proceed to trigger click if a handler is
        // defined
        if (hasZoom && self.initiated && !fireClickEvent) {
            e.preventDefault();
        }

        // Normalize each touch
        map(touches, function (e) {
            return self.normalize(e);
        });

        // Register the touch start position
        if (e.type === 'touchstart') {
            each(touches, function (e, i) {
                pinchDown[i] = { chartX: e.chartX, chartY: e.chartY };
            });
            lastValidTouch.x = [pinchDown[0].chartX, pinchDown[1] &&
                pinchDown[1].chartX];
            lastValidTouch.y = [pinchDown[0].chartY, pinchDown[1] &&
                pinchDown[1].chartY];

            // Identify the data bounds in pixels
            each(chart.axes, function (axis) {
                if (axis.zoomEnabled) {
                    var bounds = chart.bounds[axis.horiz ? 'h' : 'v'],
                        minPixelPadding = axis.minPixelPadding,
                        min = axis.toPixels(
                            pick(axis.options.min, axis.dataMin)
                        ),
                        max = axis.toPixels(
                            pick(axis.options.max, axis.dataMax)
                        ),
                        absMin = Math.min(min, max),
                        absMax = Math.max(min, max);

                    // Store the bounds for use in the touchmove handler
                    bounds.min = Math.min(axis.pos, absMin - minPixelPadding);
                    bounds.max = Math.max(
                        axis.pos + axis.len,
                        absMax + minPixelPadding
                    );
                }
            });
            self.res = true; // reset on next move

        // Optionally move the tooltip on touchmove
        } else if (self.followTouchMove && touchesLength === 1) {
            this.runPointActions(self.normalize(e));

        // Event type is touchmove, handle panning and pinching
        } else if (pinchDown.length) { // can be 0 when releasing, if touchend
                // fires first


            // Set the marker
            if (!selectionMarker) {
                self.selectionMarker = selectionMarker = extend({
                    destroy: noop,
                    touch: true
                }, chart.plotBox);
            }

            self.pinchTranslate(
                pinchDown,
                touches,
                transform,
                selectionMarker,
                clip,
                lastValidTouch
            );

            self.hasPinched = hasZoom;

            // Scale and translate the groups to provide visual feedback during
            // pinching
            self.scaleGroups(transform, clip);

            if (self.res) {
                self.res = false;
                this.reset(false, 0);
            }
        }
    },

    /**
     * General touch handler shared by touchstart and touchmove.
     *
     * @private
     * @function Highcharts.Pointer#touch
     *
     * @param {Highcharts.PointerEvent} e
     *
     * @param {boolean} start
     */
    touch: function (e, start) {
        var chart = this.chart,
            hasMoved,
            pinchDown,
            isInside;

        if (chart.index !== H.hoverChartIndex) {
            this.onContainerMouseLeave({ relatedTarget: true });
        }
        H.hoverChartIndex = chart.index;

        if (e.touches.length === 1) {

            e = this.normalize(e);

            isInside = chart.isInsidePlot(
                e.chartX - chart.plotLeft,
                e.chartY - chart.plotTop
            );
            if (isInside && !chart.openMenu) {

                // Run mouse events and display tooltip etc
                if (start) {
                    this.runPointActions(e);
                }

                // Android fires touchmove events after the touchstart even if
                // the finger hasn't moved, or moved only a pixel or two. In iOS
                // however, the touchmove doesn't fire unless the finger moves
                // more than ~4px. So we emulate this behaviour in Android by
                // checking how much it moved, and cancelling on small
                // distances. #3450.
                if (e.type === 'touchmove') {
                    pinchDown = this.pinchDown;
                    hasMoved = pinchDown[0] ? Math.sqrt( // #5266
                        Math.pow(pinchDown[0].chartX - e.chartX, 2) +
                        Math.pow(pinchDown[0].chartY - e.chartY, 2)
                    ) >= 4 : false;
                }

                if (pick(hasMoved, true)) {
                    this.pinch(e);
                }

            } else if (start) {
                // Hide the tooltip on touching outside the plot area (#1203)
                this.reset();
            }

        } else if (e.touches.length === 2) {
            this.pinch(e);
        }
    },

    /**
     * @private
     * @function Highcharts.Pointer#onContainerTouchStart
     *
     * @param {Highcharts.PointerEvent} e
     */
    onContainerTouchStart: function (e) {
        this.zoomOption(e);
        this.touch(e, true);
    },

    /**
     * @private
     * @function Highcharts.Pointer#onContainerTouchMove
     *
     * @param {Highcharts.PointerEvent} e
     */
    onContainerTouchMove: function (e) {
        this.touch(e);
    },

    /**
     * @private
     * @function Highcharts.Pointer#onDocumentTouchEnd
     *
     * @param {Highcharts.PointerEvent} e
     */
    onDocumentTouchEnd: function (e) {
        if (charts[H.hoverChartIndex]) {
            charts[H.hoverChartIndex].pointer.drop(e);
        }
    }

});