CSS3 zooming on mouse cursor

efj picture efj · Jul 16, 2012 · Viewed 12.2k times · Source

My goal is to create a plugin that enables zooming & panning operations on a page area, just like how Google Maps currently works (meaning: scrolling with the mouse = zooming in/out of the area, click & hold & move & release = panning).

When scrolling, I wish to have a zoom operation centered on the mouse cursor.

For this, I use on-the-fly CSS3 matrix transformations. The only, yet mandatory, constraint is that I cannot use anything else than CSS3 translate & scale transformations, with a transform origin of 0px 0px.

Panning is out of the scope of my question, since I have it working already. When it comes to zooming, I am struggling to figure out where the glitch is in my javascript code.

The problem must be somewhere in the MouseZoom.prototype.zoom function, in the calculation of the translation on the x axis and y axis.

First, here is my HTML code:

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width = device-width, initial-scale = 1.0, user-scalable = no" />
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
    <script src="jquery.mousewheel.min.js"></script>
    <script src="StackOverflow.js"></script>
    <style type="text/css" media="all">
        #drawing {
            position: absolute;
            top: 0px; 
            left: 0px; 
            right:0; 
            bottom:0;
            z-index: 0;
            background: url(http://catmacros.files.wordpress.com/2009/09/cats_banzai.jpg) no-repeat;
            background-position: 50% 50%;
        }
    </style>
    <title>Test</title>
</head>
<body>
    <div id="drawing"></div>
    <script>
        var renderer = new ZoomPanRenderer("drawing");
    </script>
</body>
</html>

As you can see, I am using Jquery and the jquery mouse wheel plugin from Brandon Aaron, which can be found here: https://github.com/brandonaaron/jquery-mousewheel/

Here is the content of the StackOverflow.js file:

/***************************************************** 
 * Transformations
 ****************************************************/
function Transformations(translateX, translateY, scale){
    this.translateX = translateX;
    this.translateY = translateY;
    this.scale = scale;
}

/* Getters */
Transformations.prototype.getScale = function(){ return this.scale; }
Transformations.prototype.getTranslateX = function(){ return this.translateX; }
Transformations.prototype.getTranslateY = function(){ return this.translateY; }

/***************************************************** 
 * Zoom Pan Renderer
 ****************************************************/
function ZoomPanRenderer(elementId){
    this.zooming = undefined;
    this.elementId = elementId;
    this.current = new Transformations(0, 0, 1);
    this.last = new Transformations(0, 0, 1);
    new ZoomPanEventHandlers(this);
}

/* setters */
ZoomPanRenderer.prototype.setCurrentTransformations = function(t){ this.current = t; }
ZoomPanRenderer.prototype.setZooming = function(z){ this.zooming = z; }

/* getters */
ZoomPanRenderer.prototype.getCurrentTransformations = function(){ return this.current; }
ZoomPanRenderer.prototype.getZooming = function(){ return this.zooming; }
ZoomPanRenderer.prototype.getLastTransformations = function(){ return this.last; }
ZoomPanRenderer.prototype.getElementId = function(){ return this.elementId; }

/* Rendering */
ZoomPanRenderer.prototype.getTransform3d = function(t){
    var transform3d = "matrix3d(";
    transform3d+= t.getScale().toFixed(10) + ",0,0,0,";
    transform3d+= "0," + t.getScale().toFixed(10) + ",0,0,";
    transform3d+= "0,0,1,0,";
    transform3d+= t.getTranslateX().toFixed(10) + "," + t.getTranslateY().toFixed(10)  + ",0,1)";
    return transform3d;
}

ZoomPanRenderer.prototype.getTransform2d = function(t){
    var transform3d = "matrix(";
    transform3d+= t.getScale().toFixed(10) + ",0,0," + t.getScale().toFixed(10) + "," + t.getTranslateX().toFixed(10) + "," + t.getTranslateY().toFixed(10) + ")";
    return transform3d;
}

ZoomPanRenderer.prototype.applyTransformations = function(t){
    var elem = $("#" + this.getElementId());
    elem.css("transform-origin", "0px 0px");
    elem.css("-ms-transform-origin", "0px 0px");
    elem.css("-o-transform-origin", "0px 0px");
    elem.css("-moz-transform-origin", "0px 0px");
    elem.css("-webkit-transform-origin", "0px 0px");
    var transform2d = this.getTransform2d(t);
    elem.css("transform", transform2d);
    elem.css("-ms-transform", transform2d);
    elem.css("-o-transform", transform2d);
    elem.css("-moz-transform", transform2d);
    elem.css("-webkit-transform", this.getTransform3d(t));
}

/***************************************************** 
 * Event handler
 ****************************************************/
function ZoomPanEventHandlers(renderer){
    this.renderer = renderer;

    /* Disable scroll overflow - safari */
    document.addEventListener('touchmove', function(e) { e.preventDefault(); }, false);

    /* Disable default drag opeartions on the element (FF makes it ready for save)*/
    $("#" + renderer.getElementId()).bind('dragstart', function(e) { e.preventDefault(); });

    /* Add mouse wheel handler */
    $("#" + renderer.getElementId()).bind("mousewheel", function(event, delta) {
        if(renderer.getZooming()==undefined){
            var offsetLeft = $("#" + renderer.getElementId()).offset().left;
            var offsetTop = $("#" + renderer.getElementId()).offset().top;
            var zooming = new MouseZoom(renderer.getCurrentTransformations(), event.pageX, event.pageY, offsetLeft, offsetTop, delta);
            renderer.setZooming(zooming);

            var newTransformation = zooming.zoom();
            renderer.applyTransformations(newTransformation);
            renderer.setCurrentTransformations(newTransformation);
            renderer.setZooming(undefined);
        }
        return false;
    });
}

/***************************************************** 
 * Mouse zoom
 ****************************************************/
function MouseZoom(t, mouseX, mouseY, offsetLeft, offsetTop, delta){
    this.current = t;
    this.offsetLeft = offsetLeft;
    this.offsetTop = offsetTop;
    this.mouseX = mouseX;
    this.mouseY = mouseY;
    this.delta = delta;
}

MouseZoom.prototype.zoom = function(){
    var previousScale = this.current.getScale();
    var newScale = previousScale + this.delta/5;
    if(newScale<1){
        newScale = 1;
    }
    var ratio = newScale / previousScale;

    var imageX = this.mouseX - this.offsetLeft;
    var imageY = this.mouseY - this.offsetTop;

    var previousTx = - this.current.getTranslateX() * previousScale;
    var previousTy = - this.current.getTranslateY() * previousScale;
    var previousDx = imageX * previousScale;
    var previousDy = imageY * previousScale;

    var newTx = (previousTx * ratio + previousDx * (ratio - 1)) / newScale;
    var newTy = (previousTy * ratio + previousDy * (ratio - 1)) / newScale;

    return new Transformations(-newTx, -newTy, newScale);
}

Answer

Martin Turjak picture Martin Turjak · Mar 8, 2013

Using transform to get a google maps zooming behavior on a div element seemed like an interesting idea, so I payed with it a little =)

I would use transform-origin (and its sister attributes for browser compatibility) to adjust the zooming to the mouse location on the div that you are scaling. I think this could do what you want. I put some examples on fiddle for illustration:

Adjusting the transform-origin

So in the applyTransformations function of yours we could adjust the transform-origin dynamically from the imageX and imageY, if we pass this values from the MouseZoom (mouse listener) function.

    var orig = t.getTranslateX().toFixed() + "px " + t.getTranslateY().toFixed() + "px";
    elem.css("transform-origin", orig);
    elem.css("-ms-transform-origin", orig);
    elem.css("-o-transform-origin", orig);
    elem.css("-moz-transform-origin", orig);
    elem.css("-webkit-transform-origin", orig);

(In this first fiddle example I just used your translateX and translateY in Transformations to pass the location of the mouse on the div element - in the second example I renamed it to originX and originY to differentiate from the translation variables.)

Calculating the transform origin

In your MouseZoom we can calculate origin location simply with imageX/previousScale.

    MouseZoom.prototype.zoom = function(){
        var previousScale = this.current.getScale();
        var newScale = previousScale + this.delta/10;
        if(newScale<1){
            newScale = 1;
        }
        var ratio = newScale / previousScale;

        var imageX = this.mouseX - this.offsetLeft;
        var imageY = this.mouseY - this.offsetTop;

        var newTx = imageX/previousScale;
        var newTy = imageY/previousScale;

        return new Transformations(newTx, newTy, newScale);
    }

So this will work perfectly if you zoom out completely before zooming in on a different position. But to be able to change zoom origin at any zoom level, we can combine the origin and translation functionality.

Shifting the zooming frame (extending my original answer)

The transform origin on the image is still calculated the same way but we use a separate translateX and translateY to shift the zooming frame (here I introduced two new variables that help us do the trick - so now we have originX, originY, translateX and translateY).

    MouseZoom.prototype.zoom = function(){
        // current scale
        var previousScale = this.current.getScale();
        // new scale
        var newScale = previousScale + this.delta/10;
        // scale limits
        var maxscale = 20;
        if(newScale<1){
            newScale = 1;
        }
        else if(newScale>maxscale){
            newScale = maxscale;
        }
        // current cursor position on image
        var imageX = (this.mouseX - this.offsetLeft).toFixed(2);
        var imageY = (this.mouseY - this.offsetTop).toFixed(2);
        // previous cursor position on image
        var prevOrigX = (this.current.getOriginX()*previousScale).toFixed(2);
        var prevOrigY = (this.current.getOriginY()*previousScale).toFixed(2);
        // previous zooming frame translate
        var translateX = this.current.getTranslateX();
        var translateY = this.current.getTranslateY();
        // set origin to current cursor position
        var newOrigX = imageX/previousScale;
        var newOrigY = imageY/previousScale;
        // move zooming frame to current cursor position
        if ((Math.abs(imageX-prevOrigX)>1 || Math.abs(imageY-prevOrigY)>1) && previousScale < maxscale) {
            translateX = translateX + (imageX-prevOrigX)*(1-1/previousScale);
            translateY = translateY + (imageY-prevOrigY)*(1-1/previousScale);
        }
        // stabilize position by zooming on previous cursor position
        else if(previousScale != 1 || imageX != prevOrigX && imageY != prevOrigY) {
            newOrigX = prevOrigX/previousScale;
            newOrigY = prevOrigY/previousScale;
        }
        return new Transformations(newOrigX, newOrigY, translateX, translateY, newScale);
    }

For this example I adjusted the your original script a little more and added the second fiddle example.

Now we zoom in and out on the mouse cursor from any zoom level. But because of the frame shift we end up moving the original div around ("measuring the earth") ... which looks funny if you work with an object of limited width and hight (zoom-in at one end, zoom-out at another end, and we moved forward like an inchworm).

Avoiding the "inchworm" effect

To avoid this you could for example add limitations so that the left image border can not move to the right of its original x coordinate, the top image border can not move lower than its original y position, and so on for the other two borders. But then the zoom/out will not be completely bound to the cursor, but also by the edge of the image (you will notice the image slide into place) in example 3.

    if(this.delta <= 0){
        var width = 500; // image width
        var height = 350; // image height
        if(translateX+newOrigX+(width - newOrigX)*newScale <= width){
            translateX = 0;
            newOrigX = width;
        }
        else if (translateX+newOrigX*(1-newScale) >= 0){
            translateX = 0;
            newOrigX = 0;        
        }
        if(translateY+newOrigY+(height - newOrigY)*newScale <= height){
            translateY = 0;
            newOrigY = height;
        }
        else if (translateY+newOrigY*(1-newScale) >= 0){
            translateY = 0;
            newOrigY = 0;
        }
    }

Another (a bit crappy) option would be to simply reset the frame translate when you zoom out completely (scale==1).

However, you would not have this problem if you will be dealing with continuous elements (left and right edge and top and bottom edge bound together) or just with extremely big elements.

To finish everything off with a nice touch - we can add a parent frame with hidden overflow around our scaling object. So the image area does not change with zooming. See jsfiddle example 4.