/**
 * The 'panzoom' interaction allows to react to gestures in D3 components and interpret
 * them as pan and zoom actions.
 * One can then listen to the 'panzoom' event of the interaction or implement the
 * {@link Ext.d3.Component#onPanZoom} method and receive the translation and scaling
 * components that can be applied to the content.
 * The way in which pan/zoom gestures are interpreted is highly configurable,
 * and it's also possible to show a scroll indicator.
 */
Ext.define('Ext.d3.interaction.PanZoom', {

    extend: 'Ext.d3.interaction.Abstract',

    type: 'panzoom',
    alias: 'd3.interaction.panzoom',
    isPanZoom: true,

    config: {
        /**
         * @cfg {Object} pan The pan interaction config. Set to `null` if panning is not desired.
         * @cfg {String} pan.gesture The pan gesture, must have 'start' and 'end' counterparts.
         * @cfg {Boolean} pan.constrain If `false`, the panning will be unconstrained,
         * even if {@link #contentRect} and {@link #viewportRect} configs are set.
         * @cfg {Object} pan.momentum Momentum scrolling config. Set to `null` to pan
         * with no momentum.
         * @cfg {Number} pan.momentum.friction The magnitude of the friction force.
         * @cfg {Number} pan.momentum.spring Spring constant. Spring force will be proportional
         * to the degree of viewport bounds penetration (displacement from equilibrium position),
         * as well as this spring constant.
         * See [Hooke's law](https://en.wikipedia.org/wiki/Hooke%27s_law).
         */
        pan: {
            gesture: 'drag',
            constrain: true,
            momentum: {
                friction: 1,
                spring: 0.2
            }
        },

        /**
         * @cfg {Object} zoom The zoom interaction config. Set to `null` if zooming is not desired.
         * @cfg {String} zoom.gesture The zoom gesture, must have 'start' and 'end' counterparts.
         * @cfg {Number[]} zoom.extent Minimum and maximum zoom levels as an array of two numbers.
         * @cfg {Boolean} zoom.uniform Set to `false` to allow independent scaling in both
         * directions.
         * @cfg {Object} zoom.mouseWheel Set to `null` if scaling with mouse wheel is not desired.
         * @cfg {Number} zoom.mouseWheel.factor How much to zoom in or out on each mouse wheel step.
         * @cfg {Object} zoom.doubleTap Set to `null` if zooming in on double tap is not desired.
         * @cfg {Number} zoom.doubleTap.factor How much to zoom in on double tap.
         */
        zoom: {
            gesture: 'pinch',
            extent: [1, 3],
            uniform: true,
            mouseWheel: {
                factor: 1.02
            },
            doubleTap: {
                factor: 1.1
            }
        },

        /**
         * A function that returns natural (before transformations) content position and dimensions.
         * If {@link #viewportRect} config is specified as well, constrains the panning,
         * so that the content is always visible (can't pan offscreen).
         * By default the panning is unconstrained.
         * The interaction needs to know the content's bounding box at any given time,
         * as the content can be very dynamic, e.g. animate at a time when panning or zooming action
         * is performed.
         * @cfg {Function} [contentRect]
         * @return {Object} rect
         * @return {Number} return.x
         * @return {Number} return.y
         * @return {Number} return.width
         * @return {Number} return.height
         */
        contentRect: null,

        /**
         * A function that returns viewport position and dimensions in component's coordinates.
         * If {@link #contentRect} config is specified as well, constrains the panning,
         * so that the content is always visible (can't pan offscreen).
         * By default the panning is unconstrained.
         * @cfg {Function} [viewportRect]
         * @return {Object} rect
         * @return {Number} return.x
         * @return {Number} return.y
         * @return {Number} return.width
         * @return {Number} return.height
         */
        viewportRect: null,

        /**
         * @cfg {Object} indicator The scroll indicator config. Set to `null` to disable.
         * @cfg {String} indicator.parent The name of the reference on the component to the element
         * that will be used as the scroll indicator container.
         */
        indicator: {
            parent: 'element'
        }
    },

    /**
     * @private
     * @property {Number[]} panOrigin
     * Coordinates of the initial touch/click.
     */
    panOrigin: null,
    /**
     * @private
     * Horizontal and vertical distances between original touches.
     * @property {Number[]} startSpread
     */
    startSpread: null,
    /**
     * Minimum distance between fingers in a pinch gesture.
     * If the actual distance is smaller, this value will be used.
     * @property {Number} minSpread
     */
    minSpread: 50,
    /**
     * @private
     * @property {Number[]} scaling
     */
    scaling: null,
    /**
     * @private
     * @property {Number[]} oldScaling
     */
    oldScaling: null,
    /**
     * @private
     * @property {Number[]} oldScalingCenter
     */
    oldScalingCenter: null,
    /**
     * @private
     * @property {Number[]} translation
     */
    translation: null,
    /**
     * @private
     * @property {Number[]} oldTranslation
     */
    oldTranslation: null,
    /**
     * @private
     * The amount the cursor/finger moved horizontally between the last two pan events.
     * Can be thought of as instantaneous velocity.
     * @property {Number} dx
     */
    dx: 0,
    /**
     * @private
     * The amount the cursor/finger moved vertically between the last two pan events.
     * Can be thought of as instantaneous velocity.
     * @property {Number} dy
     */
    dy: 0,

    velocityX: 0,
    velocityY: 0,

    pan: null,
    viewportRect: null,
    contentRect: null,

    updatePan: function(pan) {
        this.pan = pan;
        this.resetComponent();
    },

    updateZoom: function() {
        this.resetComponent();
    },

    updateContentRect: function(contentRect) {
        this.contentRect = contentRect;
        this.constrainTranslation();
    },

    updateViewportRect: function(viewportRect) {
        this.viewportRect = viewportRect;
        this.constrainTranslation();
    },

    constructor: function(config) {
        var me = this;

        me.translation = config && config.translation
            ? config.translation.slice()
            : [0, 0];
        me.scaling = config && config.scaling
            ? config.scaling.slice()
            : [1, 1];

        me.callParent(arguments);
    },

    destroy: function() {
        this.destroyIndicator();
        Ext.AnimationQueue.stop(this.panFxFrame, this);
        this.callParent();
    },

    /**
     * @private
     * Converts page coordinates to {@link #viewportRect viewport} coordinates.
     * @param {Number} pageX
     * @param {Number} pageY
     * @return {Number[]}
     */
    toViewportXY: function(pageX, pageY) {
        var elementXY = this.component.element.getXY(),
            viewportRect = this.viewportRect && this.viewportRect(),
            x = pageX - elementXY[0] - (viewportRect ? viewportRect.x : 0),
            y = pageY - elementXY[1] - (viewportRect ? viewportRect.y : 0);

        return [x, y];
    },

    /**
     * @private
     * Same as {@link #toViewportXY}, but accounts for RTL.
     */
    toRtlViewportXY: function(pageX, pageY) {
        var xy = this.toViewportXY(pageX, pageY),
            component = this.component;

        if (component.getInherited().rtl) {
            xy[0] = component.getWidth() - xy[0];
        }

        return xy;
    },

    /**
     * @private
     * Makes sure the given `range` is within `x` range.
     * If `y` range is specified, `x` and `y` are used to constrain the first and second
     * components of the `range` array, respectively.
     * @param {Number[]} range
     * @param {Number[]} x
     * @param {Number[]} [y]
     * @return {Number[]} The given `range` object with applied constraints.
     */
    constrainRange: function(range, x, y) {
        y = y || x;

        range[0] = Math.max(x[0], Math.min(x[1], range[0]));
        range[1] = Math.max(y[0], Math.min(y[1], range[1]));

        return range;
    },

    constrainTranslation: function(translation) {
        var me = this,
            pan = me.pan,
            constrain = pan && pan.constrain,
            momentum = pan && pan.momentum,
            contentRect = constrain && me.contentRect && me.contentRect(),
            viewportRect = constrain && me.viewportRect && me.viewportRect(),
            constraints, offLimits;

        if (contentRect && viewportRect) {
            translation = translation || me.translation;
            constraints = me.getConstraints(contentRect, viewportRect);
            offLimits = me.getOffLimits(translation, constraints);

            me.constrainRange(
                translation,
                constraints.slice(0, 2),
                constraints.slice(2)
            );

            if (offLimits && momentum && momentum.spring) { // Allow slight bounds violation.
                translation[0] += offLimits[0] * momentum.spring;
                translation[1] += offLimits[1] * momentum.spring;
            }

            me.updateIndicator(contentRect, viewportRect);
        }
    },

    /**
     * @private
     * Creates an interpolator function that maps values from an input domain
     * to the range of [0..1]. The domain can be specified via the
     * `domain` method of the interpolator. The `multiplier` is applied
     * to the calculated value in the output range before it is returned.
     * @param {Number} multiplier
     * @return {Function}
     */
    createInterpolator: function(multiplier) {
        var start = 0,
            delta = 1,
            interpolator;

        multiplier = multiplier || 1;

        interpolator = function(x) {
            var result = 0;

            if (delta) {
                result = (x - start) / delta;
                result = Math.max(0, result);
                result = Math.min(1, result);
                result *= multiplier;
            }

            return result;
        };

        interpolator.domain = function(a, b) {
            start = a;
            delta = b - a;
        };

        return interpolator;
    },

    updateIndicator: function(contentRect, viewportRect) {
        var me = this,
            hScale = me.hScrollScale,
            vScale = me.vScrollScale,
            contentX, contentY,
            contentWidth, contentHeight,
            h0, h1, v0, v1,
            sx, sy;

        contentRect = contentRect || me.contentRect && me.contentRect();
        viewportRect = viewportRect || me.viewportRect && me.viewportRect();

        if (me.hScroll && contentRect && viewportRect) {
            sx = me.scaling[0];
            sy = me.scaling[1];

            contentX = contentRect.x * sx + me.translation[0];
            contentY = contentRect.y * sy + me.translation[1];
            contentWidth = contentRect.width * sx;
            contentHeight = contentRect.height * sy;

            if (contentWidth > viewportRect.width) {
                hScale.domain(contentX, contentX + contentWidth);

                h0 = hScale(0);
                h1 = hScale(viewportRect.width);

                me.hScrollBar.style.left = h0 + '%';
                me.hScrollBar.style.width = (h1 - h0) + '%';
                me.hScroll.dom.style.display = 'block';
            }
            else {
                me.hScroll.dom.style.display = 'none';
            }

            if (contentHeight > viewportRect.height) {
                vScale.domain(contentY, contentY + contentHeight);

                v0 = vScale(0);
                v1 = vScale(viewportRect.height);

                me.vScrollBar.style.top = v0 + '%';
                me.vScrollBar.style.height = (v1 - v0) + '%';
                me.vScroll.dom.style.display = 'block';
            }
            else {
                me.vScroll.dom.style.display = 'none';
            }
        }
    },

    /**
     * @private
     * Determines the mininum and maximum translation values based on the dimensions of
     * content and viewport.
     */
    getConstraints: function(contentRect, viewportRect) {
        var me = this,
            pan = me.pan,
            result = null,
            contentX, contentY, contentWidth, contentHeight,
            sx, sy, dx, dy;

        contentRect = pan && pan.constrain && (contentRect || me.contentRect && me.contentRect());
        viewportRect = viewportRect || me.viewportRect && me.viewportRect();

        if (contentRect && viewportRect) {
            sx = me.scaling[0];
            sy = me.scaling[1];

            contentX = contentRect.x * sx;
            contentY = contentRect.y * sy;
            contentWidth = contentRect.width * sx;
            contentHeight = contentRect.height * sy;

            dx = viewportRect.width - contentWidth - contentX;
            dy = viewportRect.height - contentHeight - contentY;

            result = [
                Math.min(-contentX, dx), // minX
                Math.max(-contentX, dx), // maxX
                Math.min(-contentY, dy), // minY
                Math.max(-contentY, dy)  // maxY
            ];
        }

        return result;
    },

    /**
     * @private
     * Returns the amounts by which translation components are bigger or smaller than
     * constraints. If a value is positive/negative, the translation component is bigger/smaller
     * than maximum/minimum allowed translation by that amount.
     * @param translation
     * @param constraints
     * @param minimum
     * @return {Number[]}
     */
    getOffLimits: function(translation, constraints, minimum) {
        var me = this,
            result = null,
            minX, minY,
            maxX, maxY,
            x, y;

        constraints = constraints || me.getConstraints();
        minimum = minimum || 0.1;

        if (constraints) {
            translation = translation || me.translation;
            x = translation[0];
            y = translation[1];

            minX = constraints[0];
            maxX = constraints[1];
            minY = constraints[2];
            maxY = constraints[3];

            if (x < minX) {
                x = x - minX;
            }
            else if (x > maxX) {
                x = x - maxX;
            }
            else {
                x = 0;
            }

            if (y < minY) {
                y = y - minY;
            }
            else if (y > maxY) {
                y = y - maxY;
            }
            else {
                y = 0;
            }

            if (x && Math.abs(x) < minimum) {
                x = 0;
            }

            if (y && Math.abs(y) < minimum) {
                y = 0;
            }

            if (x || y) {
                result = [x, y];
            }
        }

        return result;
    },

    translate: function(x, y) {
        var translation = this.translation;

        this.setTranslation(
            translation[0] + x,
            translation[1] + y
        );
    },

    setTranslation: function(x, y) {
        var me = this,
            translation = me.translation;

        translation[0] = x;
        translation[1] = y;

        me.constrainTranslation(translation);

        me.fireEvent('panzoom', me, me.scaling, translation);
    },

    scale: function(sx, sy, center) {
        var scaling = this.scaling;

        this.setScaling(
            scaling[0] * sx,
            scaling[1] * sy,
            center
        );
    },

    setScaling: function(sx, sy, center) {
        var me = this,
            zoom = me.getZoom(),
            scaling = me.scaling,
            oldSx = scaling[0],
            oldSy = scaling[1],
            translation = me.translation,
            oldTranslation = me.oldTranslation,
            cx, cy;

        if (zoom) {
            if (zoom.uniform) {
                sx = sy = (sx + sy) / 2;
            }

            scaling[0] = sx;
            scaling[1] = sy;

            me.constrainRange(scaling, zoom.extent);

            // Actual scaling delta.
            sx = scaling[0] / oldSx;
            sy = scaling[1] / oldSy;

            cx = center && center[0] || 0;
            cy = center && center[1] || 0;

            /* eslint-disable max-len */
            // To scale around center we need to:
            //
            // 1) preserve existing translation
            // 2) translate coordinate grid to the center of scaling (taking existing translation into account)
            // 3) scale coordinate grid
            // 4) translate back by the same amount, this time in scaled coordinate grid
            //
            //     Step 1           Step 2           Step 3           Step 4
            //   |1  0  tx|   |1  0  (cx - tx)|   |sx  0   0|   |1  0  -(cx - tx)|   |sx  0   cx + sx * (tx - cx)|
            //   |0  1  ty| * |0  1  (cy - ty)| * |0   sy  0| * |0  1  -(cy - ty)| = |0   sy  cy + sy * (ty - cy)|
            //   |0  0   1|   |0  0      1    |   |0   0   1|   |0  0       1    |   |0   0           1          |
            //
            // Multiplying matrices in reverse order (column-major), get the result.
            /* eslint-enable max-len */

            translation[0] = cx + sx * (translation[0] - cx);
            translation[1] = cy + sy * (translation[1] - cy);

            // We won't always have oldTranslation, e.g. when scaling with mouse wheel
            // or calling the method directly.
            if (oldTranslation) {
                // The way panning while zooming is implemented in the 'onZoomGesture'
                // is the delta between original center of scaling and the current center of scaling
                // is added to the original translation (that we save in 'onZoomGestureStart').
                // However, scaling invalidates that original translation, and it needs to be
                // recalculated here.
                oldTranslation[0] = cx - (cx - oldTranslation[0]) * sx;
                oldTranslation[1] = cy - (cy - oldTranslation[1]) * sy;
            }

            me.constrainTranslation(translation);

            me.fireEvent('panzoom', me, scaling, translation);
        }
    },

    /**
     * Sets the pan and zoom values of the interaction without firing the `panzoom` event.
     * This method should be called by components that are using the interaction, but set
     * some initial translation/scaling themselves, to notify the interaction about the
     * changes they've made.
     * @param {Number[]} pan
     * @param {Number[]} zoom
     */
    setPanZoomSilently: function(pan, zoom) {
        var me = this;

        me.suspendEvent('panzoom');

        if (pan) {
            me.setTranslation(pan[0], pan[1]);
        }

        if (zoom) {
            me.setScaling(zoom[0], zoom[1]);
        }

        me.resumeEvent('panzoom');
    },

    /**
     * Sets the pan and zoom values of the interaction.
     * @param {Number[]} pan
     * @param {Number[]} zoom
     */
    setPanZoom: function(pan, zoom) {
        var me = this;

        me.setPanZoomSilently(pan, zoom);
        me.fireEvent('panzoom', me, me.scaling, me.translation);
    },

    /**
     * Normalizes the given vector represented by `x` and `y` components,
     * and optionally scales it by the `factor` (in other words, makes it
     * a certain length without changing the direction).
     * @param x
     * @param y
     * @param factor
     * @return {Number[]}
     */
    normalize: function(x, y, factor) {
        var k = (factor || 1) / this.magnitude(x, y);

        return [x * k, y * k];
    },

    /**
     * Returns the magnitude of a vector specified by `x` and `y`.
     * @param x {Number}
     * @param y {Number}
     * @return {Number}
     */
    magnitude: function(x, y) {
        return Math.sqrt(x * x + y * y);
    },

    /**
     * @private
     * @param {Object} pan The {@link #pan} config.
     */
    panFx: function(pan) {
        if (!pan.momentum) {
            return;
        }

        // eslint-disable-next-line vars-on-top
        var me = this,
            momentum = pan.momentum,
            translation = me.translation,
            offLimits = me.getOffLimits(translation),
            velocityX = me.velocityX = me.dx,
            velocityY = me.velocityY = me.dy,
            friction, spring;

        // If we let go at a time when the content is already off-screen (on x, y or both axes),
        // then we only want the spring force acting on the content to bring it back.
        // Just feels more natural this way.
        if (offLimits) {
            if (offLimits[0]) {
                me.velocityX = 0;
                velocityX = offLimits[0];
            }

            if (offLimits[1]) {
                me.velocityY = 0;
                velocityY = offLimits[1];
            }
        }

        // Both friction and spring forces can be derived from velocity,
        // by changing its magnitude and flipping direction.

        friction = me.normalize(velocityX, velocityY, -momentum.friction);
        me.frictionX = friction[0];
        me.frictionY = friction[1];

        spring = me.normalize(velocityX, velocityY, -momentum.spring);
        me.springX = spring[0];
        me.springY = spring[1];

        Ext.AnimationQueue.start(me.panFxFrame, me);
    },

    /**
     * @private
     * Increments a non-zero value without crossing zero.
     * @param {Number} x Current value.
     * @param {Number} inc Increment.
     * @return {Number}
     */
    incKeepSign: function(x, inc) {
        if (x) {
            // x * inc < 0 is only when x and inc have opposite signs.
            if (x * inc < 0 && Math.abs(x) < Math.abs(inc)) {
                return 0; // sign change is not allowed, return 0
            }
            else {
                return x + inc;
            }
        }

        return x;
    },

    panFxFrame: function() {
        var me = this,
            translation = me.translation,
            offLimits = me.getOffLimits(translation),
            offX = offLimits && offLimits[0],
            offY = offLimits && offLimits[1],
            absOffX = 0,
            absOffY = 0,
            // combined force vector
            forceX = 0,
            forceY = 0,
            // the value at which the force is considered too weak and no longer acting
            cutoff = 1,
            springX,
            springY;

        // Apply friction to velocity.
        forceX += me.velocityX = me.incKeepSign(me.velocityX, me.frictionX);
        forceY += me.velocityY = me.incKeepSign(me.velocityY, me.frictionY);

        if (offX) {
            absOffX = Math.abs(offX);
            springX = me.springX * absOffX;
            // forceX equals velocityX at this point.
            // Apply spring force to velocity.
            me.velocityX = me.incKeepSign(forceX, springX);
            // velocityX can't change sign (stops at zero),
            // but this doesn't affect combined force calculation,
            // it may very well change its direction.
            forceX += springX;

            if (Math.abs(forceX) < 0.1) {
                forceX = Ext.Number.sign(forceX) * 0.1;
            }

            // If only the spring force is left acting,
            // and it's so small, it's going to barely move the content,
            // or so strong, the content edge is going to overshoot the viewport boundary ...
            if (!(me.velocityX || Math.abs(forceX) > cutoff || me.incKeepSign(offX, forceX))) {
                forceX = -offX; // ... snap to boundary
            }
        }

        if (offY) {
            absOffY = Math.abs(offY);
            springY = me.springY * absOffY;

            me.velocityY = me.incKeepSign(forceY, springY);
            forceY += springY;

            if (Math.abs(forceY) < 0.1) {
                forceY = Ext.Number.sign(forceY) * 0.1;
            }

            if (!(me.velocityY || Math.abs(forceY) > cutoff || me.incKeepSign(offY, forceY))) {
                forceY = -offY;
            }
        }

        me.prevForceX = forceX;
        me.prevForceY = forceY;

        translation[0] += forceX;
        translation[1] += forceY;

        me.updateIndicator();

        if (!(forceX || forceY)) {
            Ext.AnimationQueue.stop(me.panFxFrame, me);
        }

        me.fireEvent('panzoom', me, me.scaling, translation);
    },

    getGestures: function() {
        var me = this,
            gestures = me.gestures,
            pan = me.getPan(),
            zoom = me.getZoom(),
            panGesture,
            zoomGesture;

        if (!gestures) {
            me.gestures = gestures = {};

            if (pan) {
                panGesture = pan.gesture;
                gestures[panGesture] = 'onPanGesture';
                gestures[panGesture + 'start'] = 'onPanGestureStart';
                gestures[panGesture + 'end'] = 'onPanGestureEnd';
                gestures[panGesture + 'cancel'] = 'onPanGestureEnd';
            }

            if (zoom) {
                zoomGesture = zoom.gesture;
                gestures[zoomGesture] = 'onZoomGesture';
                gestures[zoomGesture + 'start'] = 'onZoomGestureStart';
                gestures[zoomGesture + 'end'] = 'onZoomGestureEnd';
                gestures[zoomGesture + 'cancel'] = 'onZoomGestureEnd';

                if (zoom.doubleTap) {
                    gestures.doubletap = 'onDoubleTap';
                }

                if (zoom.mouseWheel) {
                    gestures.wheel = 'onMouseWheel';
                }
            }
        }

        return gestures;
    },

    updateGestures: function(gestures) {
        this.gestures = gestures;
    },

    isPanning: function(pan) {
        pan = pan || this.getPan();

        return pan && this.getLocks()[pan.gesture] === this;
    },

    onPanGestureStart: function(e) {
        // e.touches check is for IE11/Edge.
        // There was a bug with e.touches where reported coordinates were incorrect,
        // so a switch to e.event.touches was made, but those are not available in IE11/Edge.
        // The bug might have been fixed since then.
        var me = this,
            touches = e && e.event && e.event.touches || e.touches,
            pan = me.getPan(),
            xy;

        if (pan && (!touches || touches.length < 2)) {
            e.claimGesture();

            Ext.AnimationQueue.stop(me.panFxFrame, me);
            me.lockEvents(pan.gesture);
            xy = e.getXY();
            me.panOrigin = me.lastPanXY = me.toRtlViewportXY(xy[0], xy[1]);
            me.oldTranslation = me.translation.slice();

            return false; // stop event propagation
        }
    },

    onPanGesture: function(e) {
        var me = this,
            oldTranslation = me.oldTranslation,
            panOrigin = me.panOrigin,
            xy, dx, dy;

        if (me.isPanning()) {
            xy = e.getXY();
            xy = me.toRtlViewportXY(xy[0], xy[1]);

            me.dx = xy[0] - me.lastPanXY[0];
            me.dy = xy[1] - me.lastPanXY[1];
            me.lastPanXY = xy;

            // Displacement relative to the original touch point.
            dx = xy[0] - panOrigin[0];
            dy = xy[1] - panOrigin[1];

            me.setTranslation(
                oldTranslation[0] + dx,
                oldTranslation[1] + dy
            );

            return false;
        }
    },

    onPanGestureEnd: function() {
        var me = this,
            pan = me.getPan();

        if (me.isPanning(pan)) {
            me.panOrigin = null;
            me.oldTranslation = null;
            me.unlockEvents(pan.gesture);
            me.panFx(pan);

            return false;
        }
    },

    onZoomGestureStart: function(e) {
        var me = this,
            zoom = me.getZoom(),
            touches = e && e.event && e.event.touches || e.touches,
            point1, point2,   // points in local coordinates
            xSpread, ySpread, // distance between points
            touch1, touch2;

        if (zoom && touches && touches.length === 2) {
            e.claimGesture();

            me.lockEvents(zoom.gesture);

            me.oldTranslation = me.translation.slice();

            touch1 = touches[0];
            touch2 = touches[1];
            point1 = me.toViewportXY(touch1.pageX, touch1.pageY);
            point2 = me.toViewportXY(touch2.pageX, touch2.pageY);

            xSpread = point2[0] - point1[0];
            ySpread = point2[1] - point1[1];

            me.startSpread = [
                Math.max(me.minSpread, Math.abs(xSpread)),
                Math.max(me.minSpread, Math.abs(ySpread))
            ];

            me.oldScaling = me.scaling.slice();
            me.oldScalingCenter = [
                point1[0] + 0.5 * xSpread,
                point1[1] + 0.5 * ySpread
            ];

            return false;
        }
    },

    onZoomGesture: function(e) {
        var me = this,
            zoom = me.getZoom(),
            startSpread = me.startSpread,
            oldScaling = me.oldScaling,
            oldScalingCenter = me.oldScalingCenter,
            touches = e && e.event && e.event.touches || e.touches,
            point1, point2,   // points in local coordinates
            xSpread, ySpread, // distance between points
            xScale, yScale,   // current scaling factors
            touch1, touch2,
            scalingCenter,
            dx, dy;

        if (zoom && me.getLocks()[zoom.gesture] === me) {

            touch1 = touches[0];
            touch2 = touches[1];
            point1 = me.toViewportXY(touch1.pageX, touch1.pageY);
            point2 = me.toViewportXY(touch2.pageX, touch2.pageY);

            xSpread = point2[0] - point1[0];
            ySpread = point2[1] - point1[1];

            scalingCenter = [
                point1[0] + 0.5 * xSpread,
                point1[1] + 0.5 * ySpread
            ];

            // Displacement relative to the original zoom center.
            dx = scalingCenter[0] - oldScalingCenter[0];
            dy = scalingCenter[1] - oldScalingCenter[1];

            // For zooming to feel natural, the panning should work as well.
            me.setTranslation(
                me.oldTranslation[0] + dx,
                me.oldTranslation[1] + dy
            );

            xSpread = Math.max(me.minSpread, Math.abs(xSpread));
            ySpread = Math.max(me.minSpread, Math.abs(ySpread));

            xScale = xSpread / startSpread[0];
            yScale = ySpread / startSpread[1];

            me.setScaling(
                xScale * oldScaling[0],
                yScale * oldScaling[1],
                scalingCenter
            );

            return false;
        }
    },

    onZoomGestureEnd: function() {
        var me = this,
            zoom = me.getZoom();

        if (zoom && me.getLocks()[zoom.gesture] === me) {
            me.startSpread = null;
            me.oldScalingCenter = null;
            me.oldTranslation = null;
            me.unlockEvents(zoom.gesture);
            // Since panning while zooming is possible,
            // we have to make sure to return within bounds.
            me.dx = 0; // Though, clear velocity to avoid any weird motion,
            me.dy = 0; // after all we aren't really panning...
            me.panFx(me.getPan());

            return false;
        }
    },

    onMouseWheel: function(e) {
        var me = this,
            delta = e.getWheelDelta(), // Normalized y-delta.
            zoom = me.getZoom(),
            factor, center, xy;

        if (zoom && zoom.mouseWheel && (factor = zoom.mouseWheel.factor)) {
            factor = Math.pow(factor, delta);
            xy = e.getXY();
            center = me.toViewportXY(xy[0], xy[1]);
            me.scale(factor, factor, center);
            e.preventDefault();
        }
    },

    onDoubleTap: function(e) {
        var me = this,
            zoom = me.getZoom(),
            factor, center, xy;

        if (zoom && zoom.doubleTap && (factor = zoom.doubleTap.factor)) {
            xy = e.getXY();
            center = me.toViewportXY(xy[0], xy[1]);
            me.scale(factor, factor, center);
        }
    },

    createIndicator: function() {
        var me = this;

        if (me.hScroll) {
            return;
        }

        me.hScroll = Ext.dom.Element.create({
            tag: 'div',
            classList: [
                Ext.baseCSSPrefix + 'd3-scroll',
                Ext.baseCSSPrefix + 'd3-hscroll'
            ],
            style: {
            },
            children: [
                {
                    tag: 'div',
                    reference: 'bar',
                    classList: [
                        Ext.baseCSSPrefix + 'd3-scrollbar',
                        Ext.baseCSSPrefix + 'd3-hscrollbar'
                    ]
                }
            ]
        });
        me.hScrollBar = me.hScroll.dom.querySelector('[reference=bar]');
        me.hScrollBar.removeAttribute('reference');

        me.vScroll = Ext.dom.Element.create({
            tag: 'div',
            classList: [
                Ext.baseCSSPrefix + 'd3-scroll',
                Ext.baseCSSPrefix + 'd3-vscroll'
            ],
            style: {
            },
            children: [
                {
                    tag: 'div',
                    reference: 'bar',
                    classList: [
                        Ext.baseCSSPrefix + 'd3-scrollbar',
                        Ext.baseCSSPrefix + 'd3-vscrollbar'
                    ]
                }
            ]
        });
        me.vScrollBar = me.vScroll.dom.querySelector('[reference=bar]');
        me.vScrollBar.removeAttribute('reference');

        me.hScrollScale = me.createInterpolator(100);
        me.vScrollScale = me.createInterpolator(100);
    },

    destroyIndicator: function() {
        var me = this;

        Ext.destroy(me.hScroll, me.vScroll);
        me.hScroll = me.vScroll = me.hScrollBar = me.vScrollBar = null;
    },

    addIndicator: function(component) {
        var me = this,
            indicator = me.getIndicator(),
            indicatorParent = me.indicatorParent;

        if (indicator && !indicatorParent) {
            me.createIndicator();
            indicatorParent = me.indicatorParent = component[indicator.parent];

            if (indicatorParent) {
                indicatorParent.appendChild(me.hScroll);
                indicatorParent.appendChild(me.vScroll);
            }
        }
    },

    removeIndicator: function() {
        var me = this,
            indicatorParent = me.indicatorParent;

        if (indicatorParent) {
            if (me.hScroll) {
                indicatorParent.removeChild(me.hScroll);
            }

            if (me.vScroll) {
                indicatorParent.removeChild(me.vScroll);
            }

            me.indicatorParent = null;
        }
    },

    addComponent: function(component) {
        this.callParent([component]);
        this.addIndicator(component);
    },

    removeComponent: function(component) {
        this.removeIndicator();
        this.callParent([component]);
    }

});
