All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.plots.cartesian.dragbox.js Maven / Gradle / Ivy

The newest version!
'use strict';

var d3 = require('@plotly/d3');
var Lib = require('../../lib');
var numberFormat = Lib.numberFormat;
var tinycolor = require('tinycolor2');
var supportsPassive = require('has-passive-events');

var Registry = require('../../registry');
var strTranslate = Lib.strTranslate;
var svgTextUtils = require('../../lib/svg_text_utils');
var Color = require('../../components/color');
var Drawing = require('../../components/drawing');
var Fx = require('../../components/fx');
var Axes = require('./axes');
var setCursor = require('../../lib/setcursor');
var dragElement = require('../../components/dragelement');
var helpers = require('../../components/dragelement/helpers');
var selectingOrDrawing = helpers.selectingOrDrawing;
var freeMode = helpers.freeMode;

var FROM_TL = require('../../constants/alignment').FROM_TL;
var clearGlCanvases = require('../../lib/clear_gl_canvases');
var redrawReglTraces = require('../../plot_api/subroutines').redrawReglTraces;

var Plots = require('../plots');

var getFromId = require('./axis_ids').getFromId;
var prepSelect = require('../../components/selections').prepSelect;
var clearOutline = require('../../components/selections').clearOutline;
var selectOnClick = require('../../components/selections').selectOnClick;
var scaleZoom = require('./scale_zoom');

var constants = require('./constants');
var MINDRAG = constants.MINDRAG;
var MINZOOM = constants.MINZOOM;

// flag for showing "doubleclick to zoom out" only at the beginning
var SHOWZOOMOUTTIP = true;

// dragBox: create an element to drag one or more axis ends
// inputs:
//      plotinfo - which subplot are we making dragboxes on?
//      x,y,w,h - left, top, width, height of the box
//      ns - how does this drag the vertical axis?
//          'n' - top only
//          's' - bottom only
//          'ns' - top and bottom together, difference unchanged
//      ew - same for horizontal axis
function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) {
    // mouseDown stores ms of first mousedown event in the last
    // `gd._context.doubleClickDelay` ms on the drag bars
    // numClicks stores how many mousedowns have been seen
    // within `gd._context.doubleClickDelay` so we can check for click or doubleclick events
    // dragged stores whether a drag has occurred, so we don't have to
    // redraw unnecessarily, ie if no move bigger than MINDRAG or MINZOOM px
    var zoomlayer = gd._fullLayout._zoomlayer;
    var isMainDrag = (ns + ew === 'nsew');
    var singleEnd = (ns + ew).length === 1;

    // main subplot x and y (i.e. found in plotinfo - the main ones)
    var xa0, ya0;
    // {ax._id: ax} hash objects
    var xaHash, yaHash;
    // xaHash/yaHash values (arrays)
    var xaxes, yaxes;
    // main axis offsets
    var xs, ys;
    // main axis lengths
    var pw, ph;
    // contains keys 'xaHash', 'yaHash', 'xaxes', and 'yaxes'
    // which are the x/y {ax._id: ax} hash objects and their values
    // for linked axis relative to this subplot
    var links;
    // similar to `links` but for matching axes
    var matches;
    // set to ew/ns val when active, set to '' when inactive
    var xActive, yActive;
    // are all axes in this subplot are fixed?
    var allFixedRanges;
    // do we need to edit x/y ranges?
    var editX, editY;
    // graph-wide optimization flags
    var hasScatterGl, hasSplom, hasSVG;
    // collected changes to be made to the plot by relayout at the end
    var updates;
    // scaling factors from css transform
    var scaleX;
    var scaleY;

    // offset the x location of the box if needed
    x += plotinfo.yaxis._shift;

    function recomputeAxisLists() {
        xa0 = plotinfo.xaxis;
        ya0 = plotinfo.yaxis;
        pw = xa0._length;
        ph = ya0._length;
        xs = xa0._offset;
        ys = ya0._offset;

        xaHash = {};
        xaHash[xa0._id] = xa0;
        yaHash = {};
        yaHash[ya0._id] = ya0;

        // if we're dragging two axes at once, also drag overlays
        if(ns && ew) {
            var overlays = plotinfo.overlays;
            for(var i = 0; i < overlays.length; i++) {
                var xa = overlays[i].xaxis;
                xaHash[xa._id] = xa;
                var ya = overlays[i].yaxis;
                yaHash[ya._id] = ya;
            }
        }

        xaxes = hashValues(xaHash);
        yaxes = hashValues(yaHash);
        xActive = isDirectionActive(xaxes, ew);
        yActive = isDirectionActive(yaxes, ns);
        allFixedRanges = !yActive && !xActive;

        matches = calcLinks(gd, gd._fullLayout._axisMatchGroups, xaHash, yaHash);
        links = calcLinks(gd, gd._fullLayout._axisConstraintGroups, xaHash, yaHash, matches);
        var spConstrained = links.isSubplotConstrained || matches.isSubplotConstrained;
        editX = ew || spConstrained;
        editY = ns || spConstrained;

        var fullLayout = gd._fullLayout;
        hasScatterGl = fullLayout._has('scattergl');
        hasSplom = fullLayout._has('splom');
        hasSVG = fullLayout._has('svg');
    }

    recomputeAxisLists();

    var cursor = getDragCursor(yActive + xActive, gd._fullLayout.dragmode, isMainDrag);
    var dragger = makeRectDragger(plotinfo, ns + ew + 'drag', cursor, x, y, w, h);

    // still need to make the element if the axes are disabled
    // but nuke its events (except for maindrag which needs them for hover)
    // and stop there
    if(allFixedRanges && !isMainDrag) {
        dragger.onmousedown = null;
        dragger.style.pointerEvents = 'none';
        return dragger;
    }

    var dragOptions = {
        element: dragger,
        gd: gd,
        plotinfo: plotinfo
    };

    dragOptions.prepFn = function(e, startX, startY) {
        var dragModePrev = dragOptions.dragmode;
        var dragModeNow = gd._fullLayout.dragmode;
        if(dragModeNow !== dragModePrev) {
            dragOptions.dragmode = dragModeNow;
        }

        recomputeAxisLists();

        scaleX = gd._fullLayout._invScaleX;
        scaleY = gd._fullLayout._invScaleY;

        if(!allFixedRanges) {
            if(isMainDrag) {
                // main dragger handles all drag modes, and changes
                // to pan (or to zoom if it already is pan) on shift
                if(e.shiftKey) {
                    if(dragModeNow === 'pan') dragModeNow = 'zoom';
                    else if(!selectingOrDrawing(dragModeNow)) dragModeNow = 'pan';
                } else if(e.ctrlKey) {
                    dragModeNow = 'pan';
                }
            } else {
                // all other draggers just pan
                dragModeNow = 'pan';
            }
        }

        if(freeMode(dragModeNow)) dragOptions.minDrag = 1;
        else dragOptions.minDrag = undefined;

        if(selectingOrDrawing(dragModeNow)) {
            dragOptions.xaxes = xaxes;
            dragOptions.yaxes = yaxes;
            // this attaches moveFn, clickFn, doneFn on dragOptions
            prepSelect(e, startX, startY, dragOptions, dragModeNow);
        } else {
            dragOptions.clickFn = clickFn;
            if(selectingOrDrawing(dragModePrev)) {
                // TODO Fix potential bug
                // Note: clearing / resetting selection state only happens, when user
                // triggers at least one interaction in pan/zoom mode. Otherwise, the
                // select/lasso outlines are deleted (in plots.js.cleanPlot) but the selection
                // cache isn't cleared. So when the user switches back to select/lasso and
                // 'adds to a selection' with Shift, the "old", seemingly removed outlines
                // are redrawn again because the selection cache still holds their coordinates.
                // However, this isn't easily solved, since plots.js would need
                // to have a reference to the dragOptions object (which holds the
                // selection cache).
                clearAndResetSelect();
            }

            if(!allFixedRanges) {
                if(dragModeNow === 'zoom') {
                    dragOptions.moveFn = zoomMove;
                    dragOptions.doneFn = zoomDone;

                    // zoomMove takes care of the threshold, but we need to
                    // minimize this so that constrained zoom boxes will flip
                    // orientation at the right place
                    dragOptions.minDrag = 1;

                    zoomPrep(e, startX, startY);
                } else if(dragModeNow === 'pan') {
                    dragOptions.moveFn = plotDrag;
                    dragOptions.doneFn = dragTail;
                }
            }
        }

        gd._fullLayout._redrag = function() {
            var dragDataNow = gd._dragdata;

            if(dragDataNow && dragDataNow.element === dragger) {
                var dragModeNow = gd._fullLayout.dragmode;

                if(!selectingOrDrawing(dragModeNow)) {
                    recomputeAxisLists();
                    updateSubplots([0, 0, pw, ph]);
                    dragOptions.moveFn(dragDataNow.dx, dragDataNow.dy);
                }
            }
        };
    };

    function clearAndResetSelect() {
        // clear selection polygon cache (if any)
        dragOptions.plotinfo.selection = false;
        // clear selection outlines
        clearOutline(gd);
    }

    function clickFn(numClicks, evt) {
        var gd = dragOptions.gd;
        if(gd._fullLayout._activeShapeIndex >= 0) {
            gd._fullLayout._deactivateShape(gd);
            return;
        }

        var clickmode = gd._fullLayout.clickmode;

        removeZoombox(gd);

        if(numClicks === 2 && !singleEnd) doubleClick();

        if(isMainDrag) {
            if(clickmode.indexOf('select') > -1) {
                selectOnClick(evt, gd, xaxes, yaxes, plotinfo.id, dragOptions);
            }

            if(clickmode.indexOf('event') > -1) {
                Fx.click(gd, evt, plotinfo.id);
            }
        } else if(numClicks === 1 && singleEnd) {
            var ax = ns ? ya0 : xa0;
            var end = (ns === 's' || ew === 'w') ? 0 : 1;
            var attrStr = ax._name + '.range[' + end + ']';
            var initialText = getEndText(ax, end);
            var hAlign = 'left';
            var vAlign = 'middle';

            if(ax.fixedrange) return;

            if(ns) {
                vAlign = (ns === 'n') ? 'top' : 'bottom';
                if(ax.side === 'right') hAlign = 'right';
            } else if(ew === 'e') hAlign = 'right';

            if(gd._context.showAxisRangeEntryBoxes) {
                d3.select(dragger)
                    .call(svgTextUtils.makeEditable, {
                        gd: gd,
                        immediate: true,
                        background: gd._fullLayout.paper_bgcolor,
                        text: String(initialText),
                        fill: ax.tickfont ? ax.tickfont.color : '#444',
                        horizontalAlign: hAlign,
                        verticalAlign: vAlign
                    })
                    .on('edit', function(text) {
                        var v = ax.d2r(text);
                        if(v !== undefined) {
                            Registry.call('_guiRelayout', gd, attrStr, v);
                        }
                    });
            }
        }
    }

    dragElement.init(dragOptions);

    // x/y px position at start of drag
    var x0, y0;
    // bbox object of the zoombox
    var box;
    // luminance of bg behind zoombox
    var lum;
    // zoombox path outline
    var path0;
    // is zoombox dimmed (during drag)
    var dimmed;
    // 'x'-only, 'y' or 'xy' zooming
    var zoomMode;
    // zoombox d3 selection
    var zb;
    // zoombox corner d3 selection
    var corners;
    // zoom takes over minDrag, so it also has to take over gd._dragged
    var zoomDragged;

    function zoomPrep(e, startX, startY) {
        var dragBBox = dragger.getBoundingClientRect();
        x0 = startX - dragBBox.left;
        y0 = startY - dragBBox.top;

        gd._fullLayout._calcInverseTransform(gd);
        var transformedCoords = Lib.apply3DTransform(gd._fullLayout._invTransform)(x0, y0);
        x0 = transformedCoords[0];
        y0 = transformedCoords[1];

        box = {l: x0, r: x0, w: 0, t: y0, b: y0, h: 0};
        lum = gd._hmpixcount ?
            (gd._hmlumcount / gd._hmpixcount) :
            tinycolor(gd._fullLayout.plot_bgcolor).getLuminance();
        path0 = 'M0,0H' + pw + 'V' + ph + 'H0V0';
        dimmed = false;
        zoomMode = 'xy';
        zoomDragged = false;
        zb = makeZoombox(zoomlayer, lum, xs, ys, path0);
        corners = makeCorners(zoomlayer, xs, ys);
    }

    function zoomMove(dx0, dy0) {
        if(gd._transitioningWithDuration) {
            return false;
        }

        var x1 = Math.max(0, Math.min(pw, scaleX * dx0 + x0));
        var y1 = Math.max(0, Math.min(ph, scaleY * dy0 + y0));
        var dx = Math.abs(x1 - x0);
        var dy = Math.abs(y1 - y0);

        box.l = Math.min(x0, x1);
        box.r = Math.max(x0, x1);
        box.t = Math.min(y0, y1);
        box.b = Math.max(y0, y1);

        function noZoom() {
            zoomMode = '';
            box.r = box.l;
            box.t = box.b;
            corners.attr('d', 'M0,0Z');
        }

        if(links.isSubplotConstrained) {
            if(dx > MINZOOM || dy > MINZOOM) {
                zoomMode = 'xy';
                if(dx / pw > dy / ph) {
                    dy = dx * ph / pw;
                    if(y0 > y1) box.t = y0 - dy;
                    else box.b = y0 + dy;
                } else {
                    dx = dy * pw / ph;
                    if(x0 > x1) box.l = x0 - dx;
                    else box.r = x0 + dx;
                }
                corners.attr('d', xyCorners(box));
            } else {
                noZoom();
            }
        } else if(matches.isSubplotConstrained) {
            if(dx > MINZOOM || dy > MINZOOM) {
                zoomMode = 'xy';

                var r0 = Math.min(box.l / pw, (ph - box.b) / ph);
                var r1 = Math.max(box.r / pw, (ph - box.t) / ph);

                box.l = r0 * pw;
                box.r = r1 * pw;
                box.b = (1 - r0) * ph;
                box.t = (1 - r1) * ph;
                corners.attr('d', xyCorners(box));
            } else {
                noZoom();
            }
        } else if(!yActive || dy < Math.min(Math.max(dx * 0.6, MINDRAG), MINZOOM)) {
            // look for small drags in one direction or the other,
            // and only drag the other axis

            if(dx < MINDRAG || !xActive) {
                noZoom();
            } else {
                box.t = 0;
                box.b = ph;
                zoomMode = 'x';
                corners.attr('d', xCorners(box, y0));
            }
        } else if(!xActive || dx < Math.min(dy * 0.6, MINZOOM)) {
            box.l = 0;
            box.r = pw;
            zoomMode = 'y';
            corners.attr('d', yCorners(box, x0));
        } else {
            zoomMode = 'xy';
            corners.attr('d', xyCorners(box));
        }
        box.w = box.r - box.l;
        box.h = box.b - box.t;

        if(zoomMode) zoomDragged = true;
        gd._dragged = zoomDragged;

        updateZoombox(zb, corners, box, path0, dimmed, lum);
        computeZoomUpdates();
        gd.emit('plotly_relayouting', updates);
        dimmed = true;
    }

    function computeZoomUpdates() {
        updates = {};

        // TODO: edit linked axes in zoomAxRanges and in dragTail
        if(zoomMode === 'xy' || zoomMode === 'x') {
            zoomAxRanges(xaxes, box.l / pw, box.r / pw, updates, links.xaxes);
            updateMatchedAxRange('x', updates);
        }
        if(zoomMode === 'xy' || zoomMode === 'y') {
            zoomAxRanges(yaxes, (ph - box.b) / ph, (ph - box.t) / ph, updates, links.yaxes);
            updateMatchedAxRange('y', updates);
        }
    }

    function zoomDone() {
        computeZoomUpdates();
        removeZoombox(gd);
        dragTail();
        showDoubleClickNotifier(gd);
    }

    // scroll zoom, on all draggers except corners
    var scrollViewBox = [0, 0, pw, ph];
    // wait a little after scrolling before redrawing
    var redrawTimer = null;
    var REDRAWDELAY = constants.REDRAWDELAY;
    var mainplot = plotinfo.mainplot ? gd._fullLayout._plots[plotinfo.mainplot] : plotinfo;

    function zoomWheel(e) {
        // deactivate mousewheel scrolling on embedded graphs
        // devs can override this with layout._enablescrollzoom,
        // but _ ensures this setting won't leave their page
        if(!gd._context._scrollZoom.cartesian && !gd._fullLayout._enablescrollzoom) {
            return;
        }

        clearAndResetSelect();

        // If a transition is in progress, then disable any behavior:
        if(gd._transitioningWithDuration) {
            e.preventDefault();
            e.stopPropagation();
            return;
        }

        recomputeAxisLists();

        clearTimeout(redrawTimer);

        var wheelDelta = -e.deltaY;
        if(!isFinite(wheelDelta)) wheelDelta = e.wheelDelta / 10;
        if(!isFinite(wheelDelta)) {
            Lib.log('Did not find wheel motion attributes: ', e);
            return;
        }

        var zoom = Math.exp(-Math.min(Math.max(wheelDelta, -20), 20) / 200);
        var gbb = mainplot.draglayer.select('.nsewdrag').node().getBoundingClientRect();
        var xfrac = (e.clientX - gbb.left) / gbb.width;
        var yfrac = (gbb.bottom - e.clientY) / gbb.height;
        var i;

        function zoomWheelOneAxis(ax, centerFraction, zoom) {
            if(ax.fixedrange) return;

            var axRange = Lib.simpleMap(ax.range, ax.r2l);
            var v0 = axRange[0] + (axRange[1] - axRange[0]) * centerFraction;
            function doZoom(v) { return ax.l2r(v0 + (v - v0) * zoom); }
            ax.range = axRange.map(doZoom);
        }

        if(editX) {
            // if we're only zooming this axis because of constraints,
            // zoom it about the center
            if(!ew) xfrac = 0.5;

            for(i = 0; i < xaxes.length; i++) {
                zoomWheelOneAxis(xaxes[i], xfrac, zoom);
            }
            updateMatchedAxRange('x');

            scrollViewBox[2] *= zoom;
            scrollViewBox[0] += scrollViewBox[2] * xfrac * (1 / zoom - 1);
        }
        if(editY) {
            if(!ns) yfrac = 0.5;

            for(i = 0; i < yaxes.length; i++) {
                zoomWheelOneAxis(yaxes[i], yfrac, zoom);
            }
            updateMatchedAxRange('y');

            scrollViewBox[3] *= zoom;
            scrollViewBox[1] += scrollViewBox[3] * (1 - yfrac) * (1 / zoom - 1);
        }

        // viewbox redraw at first
        updateSubplots(scrollViewBox);
        ticksAndAnnotations();

        gd.emit('plotly_relayouting', updates);

        // then replot after a delay to make sure
        // no more scrolling is coming
        redrawTimer = setTimeout(function() {
            if(!gd._fullLayout) return;
            scrollViewBox = [0, 0, pw, ph];
            dragTail();
        }, REDRAWDELAY);

        e.preventDefault();
        return;
    }

    // everything but the corners gets wheel zoom
    if(ns.length * ew.length !== 1) {
        attachWheelEventHandler(dragger, zoomWheel);
    }

    // plotDrag: move the plot in response to a drag
    function plotDrag(dx, dy) {
        dx = dx * scaleX;
        dy = dy * scaleY;
        // If a transition is in progress, then disable any behavior:
        if(gd._transitioningWithDuration) {
            return;
        }

        // prevent axis drawing from monkeying with margins until we're done
        gd._fullLayout._replotting = true;

        if(xActive === 'ew' || yActive === 'ns') {
            var spDx = xActive ? -dx : 0;
            var spDy = yActive ? -dy : 0;

            if(matches.isSubplotConstrained) {
                if(xActive && yActive) {
                    var frac = (dx / pw - dy / ph) / 2;
                    dx = frac * pw;
                    dy = -frac * ph;
                    spDx = -dx;
                    spDy = -dy;
                }
                if(yActive) {
                    spDx = -spDy * pw / ph;
                } else {
                    spDy = -spDx * ph / pw;
                }
            }
            if(xActive) {
                dragAxList(xaxes, dx);
                updateMatchedAxRange('x');
            }
            if(yActive) {
                dragAxList(yaxes, dy);
                updateMatchedAxRange('y');
            }
            updateSubplots([spDx, spDy, pw, ph]);
            ticksAndAnnotations();
            gd.emit('plotly_relayouting', updates);
            return;
        }

        // dz: set a new value for one end (0 or 1) of an axis array axArray,
        // and return a pixel shift for that end for the viewbox
        // based on pixel drag distance d
        // TODO: this makes (generally non-fatal) errors when you get
        // near floating point limits
        function dz(axArray, end, d) {
            var otherEnd = 1 - end;
            var movedAx;
            var newLinearizedEnd;
            for(var i = 0; i < axArray.length; i++) {
                var axi = axArray[i];
                if(axi.fixedrange) continue;
                movedAx = axi;
                newLinearizedEnd = axi._rl[otherEnd] +
                    (axi._rl[end] - axi._rl[otherEnd]) / dZoom(d / axi._length);
                var newEnd = axi.l2r(newLinearizedEnd);

                // if l2r comes back false or undefined, it means we've dragged off
                // the end of valid ranges - so stop.
                if(newEnd !== false && newEnd !== undefined) axi.range[end] = newEnd;
            }
            return movedAx._length * (movedAx._rl[end] - newLinearizedEnd) /
                (movedAx._rl[end] - movedAx._rl[otherEnd]);
        }

        var dxySign = ((xActive === 'w') === (yActive === 'n')) ? 1 : -1;
        if(xActive && yActive && (links.isSubplotConstrained || matches.isSubplotConstrained)) {
            // dragging a corner of a constrained subplot:
            // respect the fixed corner, but harmonize dx and dy
            var dxyFraction = (dx / pw + dxySign * dy / ph) / 2;
            dx = dxyFraction * pw;
            dy = dxySign * dxyFraction * ph;
        }

        var xStart, yStart;

        if(xActive === 'w') dx = dz(xaxes, 0, dx);
        else if(xActive === 'e') dx = dz(xaxes, 1, -dx);
        else if(!xActive) dx = 0;

        if(yActive === 'n') dy = dz(yaxes, 1, dy);
        else if(yActive === 's') dy = dz(yaxes, 0, -dy);
        else if(!yActive) dy = 0;

        xStart = (xActive === 'w') ? dx : 0;
        yStart = (yActive === 'n') ? dy : 0;

        if(
            (links.isSubplotConstrained && !matches.isSubplotConstrained) ||
            // NW or SE on matching axes - create a symmetric zoom
            (matches.isSubplotConstrained && xActive && yActive && dxySign > 0)
        ) {
            var i;
            if(matches.isSubplotConstrained || (!xActive && yActive.length === 1)) {
                // dragging one end of the y axis of a constrained subplot
                // scale the other axis the same about its middle
                for(i = 0; i < xaxes.length; i++) {
                    xaxes[i].range = xaxes[i]._r.slice();
                    scaleZoom(xaxes[i], 1 - dy / ph);
                }
                dx = dy * pw / ph;
                xStart = dx / 2;
            }
            if(matches.isSubplotConstrained || (!yActive && xActive.length === 1)) {
                for(i = 0; i < yaxes.length; i++) {
                    yaxes[i].range = yaxes[i]._r.slice();
                    scaleZoom(yaxes[i], 1 - dx / pw);
                }
                dy = dx * ph / pw;
                yStart = dy / 2;
            }
        }

        if(!matches.isSubplotConstrained || !yActive) {
            updateMatchedAxRange('x');
        }
        if(!matches.isSubplotConstrained || !xActive) {
            updateMatchedAxRange('y');
        }
        var xSize = pw - dx;
        var ySize = ph - dy;
        if(matches.isSubplotConstrained && !(xActive && yActive)) {
            if(xActive) {
                yStart = xStart ? 0 : (dx * ph / pw);
                ySize = xSize * ph / pw;
            } else {
                xStart = yStart ? 0 : (dy * pw / ph);
                xSize = ySize * pw / ph;
            }
        }
        updateSubplots([xStart, yStart, xSize, ySize]);
        ticksAndAnnotations();
        gd.emit('plotly_relayouting', updates);
    }

    function updateMatchedAxRange(axLetter, out) {
        var matchedAxes = matches.isSubplotConstrained ?
            {x: yaxes, y: xaxes}[axLetter] :
            matches[axLetter + 'axes'];

        var constrainedAxes = matches.isSubplotConstrained ?
            {x: xaxes, y: yaxes}[axLetter] :
            [];

        for(var i = 0; i < matchedAxes.length; i++) {
            var ax = matchedAxes[i];
            var axId = ax._id;
            var axId2 = matches.xLinks[axId] || matches.yLinks[axId];
            var ax2 = constrainedAxes[0] || xaHash[axId2] || yaHash[axId2];

            if(ax2) {
                if(out) {
                    // zoombox case - don't mutate 'range', just add keys in 'updates'
                    out[ax._name + '.range[0]'] = out[ax2._name + '.range[0]'];
                    out[ax._name + '.range[1]'] = out[ax2._name + '.range[1]'];
                } else {
                    ax.range = ax2.range.slice();
                }
            }
        }
    }

    // Draw ticks and annotations (and other components) when ranges change.
    // Also records the ranges that have changed for use by update at the end.
    function ticksAndAnnotations() {
        var activeAxIds = [];
        var i;

        function pushActiveAxIds(axList) {
            for(i = 0; i < axList.length; i++) {
                if(!axList[i].fixedrange) activeAxIds.push(axList[i]._id);
            }
        }

        function pushActiveAxIdsSynced(axList, axisType) {
            for(i = 0; i < axList.length; i++) {
                var axListI = axList[i];
                var axListIType = axListI[axisType];
                if(!axListI.fixedrange && axListIType.tickmode === 'sync') activeAxIds.push(axListIType._id);
            }
        }

        if(editX) {
            pushActiveAxIds(xaxes);
            pushActiveAxIds(links.xaxes);
            pushActiveAxIds(matches.xaxes);
            pushActiveAxIdsSynced(plotinfo.overlays, 'xaxis');
        }
        if(editY) {
            pushActiveAxIds(yaxes);
            pushActiveAxIds(links.yaxes);
            pushActiveAxIds(matches.yaxes);
            pushActiveAxIdsSynced(plotinfo.overlays, 'yaxis');
        }

        updates = {};
        for(i = 0; i < activeAxIds.length; i++) {
            var axId = activeAxIds[i];
            var ax = getFromId(gd, axId);
            Axes.drawOne(gd, ax, {skipTitle: true});
            updates[ax._name + '.range[0]'] = ax.range[0];
            updates[ax._name + '.range[1]'] = ax.range[1];
        }

        Axes.redrawComponents(gd, activeAxIds);
    }

    function doubleClick() {
        if(gd._transitioningWithDuration) return;

        var doubleClickConfig = gd._context.doubleClick;

        var axList = [];
        if(xActive) axList = axList.concat(xaxes);
        if(yActive) axList = axList.concat(yaxes);
        if(matches.xaxes) axList = axList.concat(matches.xaxes);
        if(matches.yaxes) axList = axList.concat(matches.yaxes);

        var attrs = {};
        var ax, i;

        // For reset+autosize mode:
        // If *any* of the main axes is not at its initial range
        // (or autoranged, if we have no initial range, to match the logic in
        // doubleClickConfig === 'reset' below), we reset.
        // If they are *all* at their initial ranges, then we autosize.
        if(doubleClickConfig === 'reset+autosize') {
            doubleClickConfig = 'autosize';

            for(i = 0; i < axList.length; i++) {
                ax = axList[i];
                var r0 = ax._rangeInitial0;
                var r1 = ax._rangeInitial1;
                var hasRangeInitial =
                    r0 !== undefined ||
                    r1 !== undefined;

                if((hasRangeInitial && (
                        (r0 !== undefined && r0 !== ax.range[0]) ||
                        (r1 !== undefined && r1 !== ax.range[1])
                    )) ||
                    (!hasRangeInitial && ax.autorange !== true)
                ) {
                    doubleClickConfig = 'reset';
                    break;
                }
            }
        }

        if(doubleClickConfig === 'autosize') {
            // don't set the linked axes here, so relayout marks them as shrinkable
            // and we autosize just to the requested axis/axes
            for(i = 0; i < axList.length; i++) {
                ax = axList[i];
                if(!ax.fixedrange) attrs[ax._name + '.autorange'] = true;
            }
        } else if(doubleClickConfig === 'reset') {
            // when we're resetting, reset all linked axes too, so we get back
            // to the fully-auto-with-constraints situation
            if(xActive || links.isSubplotConstrained) axList = axList.concat(links.xaxes);
            if(yActive && !links.isSubplotConstrained) axList = axList.concat(links.yaxes);

            if(links.isSubplotConstrained) {
                if(!xActive) axList = axList.concat(xaxes);
                else if(!yActive) axList = axList.concat(yaxes);
            }

            for(i = 0; i < axList.length; i++) {
                ax = axList[i];

                if(!ax.fixedrange) {
                    var axName = ax._name;

                    var autorangeInitial = ax._autorangeInitial;
                    if(ax._rangeInitial0 === undefined && ax._rangeInitial1 === undefined) {
                        attrs[axName + '.autorange'] = true;
                    } else if(ax._rangeInitial0 === undefined) {
                        attrs[axName + '.autorange'] = autorangeInitial;
                        attrs[axName + '.range'] = [null, ax._rangeInitial1];
                    } else if(ax._rangeInitial1 === undefined) {
                        attrs[axName + '.range'] = [ax._rangeInitial0, null];
                        attrs[axName + '.autorange'] = autorangeInitial;
                    } else {
                        attrs[axName + '.range'] = [ax._rangeInitial0, ax._rangeInitial1];
                    }
                }
            }
        }

        gd.emit('plotly_doubleclick', null);
        Registry.call('_guiRelayout', gd, attrs);
    }

    // dragTail - finish a drag event with a redraw
    function dragTail() {
        // put the subplot viewboxes back to default (Because we're going to)
        // be repositioning the data in the relayout. But DON'T call
        // ticksAndAnnotations again - it's unnecessary and would overwrite `updates`
        updateSubplots([0, 0, pw, ph]);

        // since we may have been redrawing some things during the drag, we may have
        // accumulated MathJax promises - wait for them before we relayout.
        Lib.syncOrAsync([
            Plots.previousPromises,
            function() {
                gd._fullLayout._replotting = false;
                Registry.call('_guiRelayout', gd, updates);
            }
        ], gd);
    }

    // updateSubplots - find all plot viewboxes that should be
    // affected by this drag, and update them. look for all plots
    // sharing an affected axis (including the one being dragged),
    // includes also scattergl and splom logic.
    function updateSubplots(viewBox) {
        var fullLayout = gd._fullLayout;
        var plotinfos = fullLayout._plots;
        var subplots = fullLayout._subplots.cartesian;
        var i, sp, xa, ya;

        if(hasSplom) {
            Registry.subplotsRegistry.splom.drag(gd);
        }

        if(hasScatterGl) {
            for(i = 0; i < subplots.length; i++) {
                sp = plotinfos[subplots[i]];
                xa = sp.xaxis;
                ya = sp.yaxis;

                if(sp._scene) {
                    if(xa.limitRange) xa.limitRange();
                    if(ya.limitRange) ya.limitRange();

                    var xrng = Lib.simpleMap(xa.range, xa.r2l);
                    var yrng = Lib.simpleMap(ya.range, ya.r2l);

                    sp._scene.update({range: [xrng[0], yrng[0], xrng[1], yrng[1]]});
                }
            }
        }

        if(hasSplom || hasScatterGl) {
            clearGlCanvases(gd);
            redrawReglTraces(gd);
        }

        if(hasSVG) {
            var xScaleFactor = viewBox[2] / xa0._length;
            var yScaleFactor = viewBox[3] / ya0._length;

            for(i = 0; i < subplots.length; i++) {
                sp = plotinfos[subplots[i]];
                xa = sp.xaxis;
                ya = sp.yaxis;

                var editX2 = (editX || matches.isSubplotConstrained) && !xa.fixedrange && xaHash[xa._id];
                var editY2 = (editY || matches.isSubplotConstrained) && !ya.fixedrange && yaHash[ya._id];

                var xScaleFactor2, yScaleFactor2;
                var clipDx, clipDy;

                if(editX2) {
                    xScaleFactor2 = xScaleFactor;
                    clipDx = ew || matches.isSubplotConstrained ? viewBox[0] : getShift(xa, xScaleFactor2);
                } else if(matches.xaHash[xa._id]) {
                    xScaleFactor2 = xScaleFactor;
                    clipDx = viewBox[0] * xa._length / xa0._length;
                } else if(matches.yaHash[xa._id]) {
                    xScaleFactor2 = yScaleFactor;
                    clipDx = yActive === 'ns' ?
                        -viewBox[1] * xa._length / ya0._length :
                        getShift(xa, xScaleFactor2, {n: 'top', s: 'bottom'}[yActive]);
                } else {
                    xScaleFactor2 = getLinkedScaleFactor(xa, xScaleFactor, yScaleFactor);
                    clipDx = scaleAndGetShift(xa, xScaleFactor2);
                }

                if(xScaleFactor2 > 1 && (
                    (xa.maxallowed !== undefined && editX === (xa.range[0] < xa.range[1] ? 'e' : 'w')) ||
                    (xa.minallowed !== undefined && editX === (xa.range[0] < xa.range[1] ? 'w' : 'e'))
                )) {
                    xScaleFactor2 = 1;
                    clipDx = 0;
                }

                if(editY2) {
                    yScaleFactor2 = yScaleFactor;
                    clipDy = ns || matches.isSubplotConstrained ? viewBox[1] : getShift(ya, yScaleFactor2);
                } else if(matches.yaHash[ya._id]) {
                    yScaleFactor2 = yScaleFactor;
                    clipDy = viewBox[1] * ya._length / ya0._length;
                } else if(matches.xaHash[ya._id]) {
                    yScaleFactor2 = xScaleFactor;
                    clipDy = xActive === 'ew' ?
                        -viewBox[0] * ya._length / xa0._length :
                        getShift(ya, yScaleFactor2, {e: 'right', w: 'left'}[xActive]);
                } else {
                    yScaleFactor2 = getLinkedScaleFactor(ya, xScaleFactor, yScaleFactor);
                    clipDy = scaleAndGetShift(ya, yScaleFactor2);
                }

                if(yScaleFactor2 > 1 && (
                    (ya.maxallowed !== undefined && editY === (ya.range[0] < ya.range[1] ? 'n' : 's')) ||
                    (ya.minallowed !== undefined && editY === (ya.range[0] < ya.range[1] ? 's' : 'n'))
                )) {
                    yScaleFactor2 = 1;
                    clipDy = 0;
                }

                // don't scale at all if neither axis is scalable here
                if(!xScaleFactor2 && !yScaleFactor2) {
                    continue;
                }

                // but if only one is, reset the other axis scaling
                if(!xScaleFactor2) xScaleFactor2 = 1;
                if(!yScaleFactor2) yScaleFactor2 = 1;

                var plotDx = xa._offset - clipDx / xScaleFactor2;
                var plotDy = ya._offset - clipDy / yScaleFactor2;

                // TODO could be more efficient here:
                // setTranslate and setScale do a lot of extra work
                // when working independently, should perhaps combine
                // them into a single routine.
                sp.clipRect
                    .call(Drawing.setTranslate, clipDx, clipDy)
                    .call(Drawing.setScale, xScaleFactor2, yScaleFactor2);

                sp.plot
                    .call(Drawing.setTranslate, plotDx, plotDy)
                    .call(Drawing.setScale, 1 / xScaleFactor2, 1 / yScaleFactor2);

                // apply an inverse scale to individual points to counteract
                // the scale of the trace group.
                // apply only when scale changes, as adjusting the scale of
                // all the points can be expansive.
                if(xScaleFactor2 !== sp.xScaleFactor || yScaleFactor2 !== sp.yScaleFactor) {
                    Drawing.setPointGroupScale(sp.zoomScalePts, xScaleFactor2, yScaleFactor2);
                    Drawing.setTextPointsScale(sp.zoomScaleTxt, xScaleFactor2, yScaleFactor2);
                }

                Drawing.hideOutsideRangePoints(sp.clipOnAxisFalseTraces, sp);

                // update x/y scaleFactor stash
                sp.xScaleFactor = xScaleFactor2;
                sp.yScaleFactor = yScaleFactor2;
            }
        }
    }

    // Find the appropriate scaling for this axis, if it's linked to the
    // dragged axes by constraints. 0 is special, it means this axis shouldn't
    // ever be scaled (will be converted to 1 if the other axis is scaled)
    function getLinkedScaleFactor(ax, xScaleFactor, yScaleFactor) {
        if(ax.fixedrange) return 0;

        if(editX && links.xaHash[ax._id]) {
            return xScaleFactor;
        }
        if(editY && (links.isSubplotConstrained ? links.xaHash : links.yaHash)[ax._id]) {
            return yScaleFactor;
        }
        return 0;
    }

    function scaleAndGetShift(ax, scaleFactor) {
        if(scaleFactor) {
            ax.range = ax._r.slice();
            scaleZoom(ax, scaleFactor);
            return getShift(ax, scaleFactor);
        }
        return 0;
    }

    function getShift(ax, scaleFactor, from) {
        return ax._length * (1 - scaleFactor) * FROM_TL[from || ax.constraintoward || 'middle'];
    }

    return dragger;
}

function makeDragger(plotinfo, nodeName, dragClass, cursor) {
    var dragger3 = Lib.ensureSingle(plotinfo.draglayer, nodeName, dragClass, function(s) {
        s.classed('drag', true)
            .style({fill: 'transparent', 'stroke-width': 0})
            .attr('data-subplot', plotinfo.id);
    });

    dragger3.call(setCursor, cursor);

    return dragger3.node();
}

function makeRectDragger(plotinfo, dragClass, cursor, x, y, w, h) {
    var dragger = makeDragger(plotinfo, 'rect', dragClass, cursor);
    d3.select(dragger).call(Drawing.setRect, x, y, w, h);
    return dragger;
}

function isDirectionActive(axList, activeVal) {
    for(var i = 0; i < axList.length; i++) {
        if(!axList[i].fixedrange) return activeVal;
    }
    return '';
}

function getEndText(ax, end) {
    var initialVal = ax.range[end];
    var diff = Math.abs(initialVal - ax.range[1 - end]);
    var dig;

    // TODO: this should basically be ax.r2d but we're doing extra
    // rounding here... can we clean up at all?
    if(ax.type === 'date') {
        return initialVal;
    } else if(ax.type === 'log') {
        dig = Math.ceil(Math.max(0, -Math.log(diff) / Math.LN10)) + 3;
        return numberFormat('.' + dig + 'g')(Math.pow(10, initialVal));
    } else { // linear numeric (or category... but just show numbers here)
        dig = Math.floor(Math.log(Math.abs(initialVal)) / Math.LN10) -
            Math.floor(Math.log(diff) / Math.LN10) + 4;
        return numberFormat('.' + String(dig) + 'g')(initialVal);
    }
}

function zoomAxRanges(axList, r0Fraction, r1Fraction, updates, linkedAxes) {
    for(var i = 0; i < axList.length; i++) {
        var axi = axList[i];
        if(axi.fixedrange) continue;

        if(axi.rangebreaks) {
            var isY = axi._id.charAt(0) === 'y';
            var r0F = isY ? (1 - r0Fraction) : r0Fraction;
            var r1F = isY ? (1 - r1Fraction) : r1Fraction;

            updates[axi._name + '.range[0]'] = axi.l2r(axi.p2l(r0F * axi._length));
            updates[axi._name + '.range[1]'] = axi.l2r(axi.p2l(r1F * axi._length));
        } else {
            var axRangeLinear0 = axi._rl[0];
            var axRangeLinearSpan = axi._rl[1] - axRangeLinear0;
            updates[axi._name + '.range[0]'] = axi.l2r(axRangeLinear0 + axRangeLinearSpan * r0Fraction);
            updates[axi._name + '.range[1]'] = axi.l2r(axRangeLinear0 + axRangeLinearSpan * r1Fraction);
        }
    }

    // zoom linked axes about their centers
    if(linkedAxes && linkedAxes.length) {
        var linkedR0Fraction = (r0Fraction + (1 - r1Fraction)) / 2;
        zoomAxRanges(linkedAxes, linkedR0Fraction, 1 - linkedR0Fraction, updates, []);
    }
}

function dragAxList(axList, pix) {
    for(var i = 0; i < axList.length; i++) {
        var axi = axList[i];
        if(!axi.fixedrange) {
            if(axi.rangebreaks) {
                var p0 = 0;
                var p1 = axi._length;
                var d0 = axi.p2l(p0 + pix) - axi.p2l(p0);
                var d1 = axi.p2l(p1 + pix) - axi.p2l(p1);
                var delta = (d0 + d1) / 2;

                axi.range = [
                    axi.l2r(axi._rl[0] - delta),
                    axi.l2r(axi._rl[1] - delta)
                ];
            } else {
                axi.range = [
                    axi.l2r(axi._rl[0] - pix / axi._m),
                    axi.l2r(axi._rl[1] - pix / axi._m)
                ];
            }

            if(axi.limitRange) axi.limitRange();
        }
    }
}

// common transform for dragging one end of an axis
// d>0 is compressing scale (cursor is over the plot,
//  the axis end should move with the cursor)
// d<0 is expanding (cursor is off the plot, axis end moves
//  nonlinearly so you can expand far)
function dZoom(d) {
    return 1 - ((d >= 0) ? Math.min(d, 0.9) :
        1 / (1 / Math.max(d, -0.3) + 3.222));
}

function getDragCursor(nsew, dragmode, isMainDrag) {
    if(!nsew) return 'pointer';
    if(nsew === 'nsew') {
        // in this case here, clear cursor and
        // use the cursor style set on 
        if(isMainDrag) return '';
        if(dragmode === 'pan') return 'move';
        return 'crosshair';
    }
    return nsew.toLowerCase() + '-resize';
}

function makeZoombox(zoomlayer, lum, xs, ys, path0) {
    return zoomlayer.append('path')
        .attr('class', 'zoombox')
        .style({
            fill: lum > 0.2 ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)',
            'stroke-width': 0
        })
        .attr('transform', strTranslate(xs, ys))
        .attr('d', path0 + 'Z');
}

function makeCorners(zoomlayer, xs, ys) {
    return zoomlayer.append('path')
        .attr('class', 'zoombox-corners')
        .style({
            fill: Color.background,
            stroke: Color.defaultLine,
            'stroke-width': 1,
            opacity: 0
        })
        .attr('transform', strTranslate(xs, ys))
        .attr('d', 'M0,0Z');
}

function updateZoombox(zb, corners, box, path0, dimmed, lum) {
    zb.attr('d',
        path0 + 'M' + (box.l) + ',' + (box.t) + 'v' + (box.h) +
        'h' + (box.w) + 'v-' + (box.h) + 'h-' + (box.w) + 'Z');
    transitionZoombox(zb, corners, dimmed, lum);
}

function transitionZoombox(zb, corners, dimmed, lum) {
    if(!dimmed) {
        zb.transition()
            .style('fill', lum > 0.2 ? 'rgba(0,0,0,0.4)' :
                'rgba(255,255,255,0.3)')
            .duration(200);
        corners.transition()
            .style('opacity', 1)
            .duration(200);
    }
}

function removeZoombox(gd) {
    d3.select(gd)
        .selectAll('.zoombox,.js-zoombox-backdrop,.js-zoombox-menu,.zoombox-corners')
        .remove();
}

function showDoubleClickNotifier(gd) {
    if(SHOWZOOMOUTTIP && gd.data && gd._context.showTips) {
        Lib.notifier(Lib._(gd, 'Double-click to zoom back out'), 'long');
        SHOWZOOMOUTTIP = false;
    }
}

function xCorners(box, y0) {
    return 'M' +
        (box.l - 0.5) + ',' + (y0 - MINZOOM - 0.5) +
        'h-3v' + (2 * MINZOOM + 1) + 'h3ZM' +
        (box.r + 0.5) + ',' + (y0 - MINZOOM - 0.5) +
        'h3v' + (2 * MINZOOM + 1) + 'h-3Z';
}

function yCorners(box, x0) {
    return 'M' +
        (x0 - MINZOOM - 0.5) + ',' + (box.t - 0.5) +
        'v-3h' + (2 * MINZOOM + 1) + 'v3ZM' +
        (x0 - MINZOOM - 0.5) + ',' + (box.b + 0.5) +
        'v3h' + (2 * MINZOOM + 1) + 'v-3Z';
}

function xyCorners(box) {
    var clen = Math.floor(Math.min(box.b - box.t, box.r - box.l, MINZOOM) / 2);
    return 'M' +
        (box.l - 3.5) + ',' + (box.t - 0.5 + clen) + 'h3v' + (-clen) +
            'h' + clen + 'v-3h-' + (clen + 3) + 'ZM' +
        (box.r + 3.5) + ',' + (box.t - 0.5 + clen) + 'h-3v' + (-clen) +
            'h' + (-clen) + 'v-3h' + (clen + 3) + 'ZM' +
        (box.r + 3.5) + ',' + (box.b + 0.5 - clen) + 'h-3v' + clen +
            'h' + (-clen) + 'v3h' + (clen + 3) + 'ZM' +
        (box.l - 3.5) + ',' + (box.b + 0.5 - clen) + 'h3v' + clen +
            'h' + clen + 'v3h-' + (clen + 3) + 'Z';
}

function calcLinks(gd, groups, xaHash, yaHash, exclude) {
    var isSubplotConstrained = false;
    var xLinks = {};
    var yLinks = {};
    var xID, yID, xLinkID, yLinkID;
    var xExclude = (exclude || {}).xaHash;
    var yExclude = (exclude || {}).yaHash;

    for(var i = 0; i < groups.length; i++) {
        var group = groups[i];
        // check if any of the x axes we're dragging is in this constraint group
        for(xID in xaHash) {
            if(group[xID]) {
                // put the rest of these axes into xLinks, if we're not already
                // dragging them, so we know to scale these axes automatically too
                // to match the changes in the dragged x axes
                for(xLinkID in group) {
                    if(
                        !(exclude && (xExclude[xLinkID] || yExclude[xLinkID])) &&
                        !(xLinkID.charAt(0) === 'x' ? xaHash : yaHash)[xLinkID]
                    ) {
                        xLinks[xLinkID] = xID;
                    }
                }

                // check if the x and y axes of THIS drag are linked
                for(yID in yaHash) {
                    if(
                        !(exclude && (xExclude[yID] || yExclude[yID])) &&
                        group[yID]
                    ) {
                        isSubplotConstrained = true;
                    }
                }
            }
        }

        // now check if any of the y axes we're dragging is in this constraint group
        // only look for outside links, as we've already checked for links within the dragger
        for(yID in yaHash) {
            if(group[yID]) {
                for(yLinkID in group) {
                    if(
                        !(exclude && (xExclude[yLinkID] || yExclude[yLinkID])) &&
                        !(yLinkID.charAt(0) === 'x' ? xaHash : yaHash)[yLinkID]
                    ) {
                        yLinks[yLinkID] = yID;
                    }
                }
            }
        }
    }

    if(isSubplotConstrained) {
        // merge xLinks and yLinks if the subplot is constrained,
        // since we'll always apply both anyway and the two will contain
        // duplicates
        Lib.extendFlat(xLinks, yLinks);
        yLinks = {};
    }

    var xaHashLinked = {};
    var xaxesLinked = [];
    for(xLinkID in xLinks) {
        var xa = getFromId(gd, xLinkID);
        xaxesLinked.push(xa);
        xaHashLinked[xa._id] = xa;
    }

    var yaHashLinked = {};
    var yaxesLinked = [];
    for(yLinkID in yLinks) {
        var ya = getFromId(gd, yLinkID);
        yaxesLinked.push(ya);
        yaHashLinked[ya._id] = ya;
    }

    return {
        xaHash: xaHashLinked,
        yaHash: yaHashLinked,
        xaxes: xaxesLinked,
        yaxes: yaxesLinked,
        xLinks: xLinks,
        yLinks: yLinks,
        isSubplotConstrained: isSubplotConstrained
    };
}

// still seems to be some confusion about onwheel vs onmousewheel...
function attachWheelEventHandler(element, handler) {
    if(!supportsPassive) {
        if(element.onwheel !== undefined) element.onwheel = handler;
        else if(element.onmousewheel !== undefined) element.onmousewheel = handler;
        else if(!element.isAddedWheelEvent) {
            element.isAddedWheelEvent = true;
            element.addEventListener('wheel', handler, {passive: false});
        }
    } else {
        var wheelEventName = element.onwheel !== undefined ? 'wheel' : 'mousewheel';

        if(element._onwheel) {
            element.removeEventListener(wheelEventName, element._onwheel);
        }
        element._onwheel = handler;

        element.addEventListener(wheelEventName, handler, {passive: false});
    }
}

function hashValues(hash) {
    var out = [];
    for(var k in hash) out.push(hash[k]);
    return out;
}

module.exports = {
    makeDragBox: makeDragBox,

    makeDragger: makeDragger,
    makeRectDragger: makeRectDragger,
    makeZoombox: makeZoombox,
    makeCorners: makeCorners,

    updateZoombox: updateZoombox,
    xyCorners: xyCorners,
    transitionZoombox: transitionZoombox,
    removeZoombox: removeZoombox,
    showDoubleClickNotifier: showDoubleClickNotifier,

    attachWheelEventHandler: attachWheelEventHandler
};




© 2015 - 2024 Weber Informatics LLC | Privacy Policy