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);
}
If your mouse does not have a wheel, hold the Windows key and + (plus) or - (minus) to increase or decrease magnification.
To zoom in and out using the mouse, hold down the [Ctrl] key while you turn the mouse wheel. Each click, up or down, increases or decreases the zoom factor by 10%.
You can't increase the size of the default cursor using CSS or jQuery. But you can replace the cursor with the custom image of any size using pure CSS by cursor : Url() property.
If you have ever visited any online shopping website, then you must have seen the following rollover zoom effect, in which you hover over different parts of a product image to see an enlarged preview of that portion. Following is a demo of such an effect. Hover over the two images to see their enlarged view.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With