Pinch Zoom Algorithm

13,755 views
Skip to first unread message

apple...@mac.com

unread,
Jun 11, 2012, 8:17:56 PM6/11/12
to phonegap

Hi,

Does anyone know of a really good Javascript pinch-zooming algorithm?
This is trickier than I had imagined, as you have to scale -AND-
translate to keep the pinch zooming centered between your fingers.

And I want it to do pure translation when the user moves two fingers -
kept an equal distance apart from each other - around the screen,
i.e., this moving two fingers in unison should act like a single-
finger "pan".

I've searched all day for an answer on the Interwebs. No luck.

Thanks,

Jack

Devgeeks

unread,
Jun 11, 2012, 8:44:21 PM6/11/12
to phon...@googlegroups.com
I use iScroll4 for this on images.


It even includes double tap to zoom, bounce back at the boundaries, etc etc. 

Kerri Shotts

unread,
Jun 11, 2012, 10:38:12 PM6/11/12
to phon...@googlegroups.com
Remember your geometry -- the shortest linear distance between two points is the hypotnuse of a right triangle, which means you can use the pythagorean theorem to do quite a lot of your work:

let ∆x = | x1 - x2 |
let ∆y = | y1 - y2 |
let distance = √(∆x^2 • ∆y^2)

Then:
let ∆d = distance2 - distance1
if |∆d| < p, the two touches are parallel, with p being your desired precision
if ∆d < -p, then the second distance is smaller than the first; pinch
else, zoom

It follows that,
scale = distance2 / distance1
center = min(x1,x2) + ∆x/2, min(y1,y2) + ∆y/2

I. Think. It's been a while since I had to think that out. Don't ask about rotation -- my brain says no more math! ;-)

Or just use hammer.js: http://eightmedia.github.com/hammer.js/

apple...@mac.com

unread,
Jun 11, 2012, 11:25:42 PM6/11/12
to phon...@googlegroups.com
Kerri,

It turns out to be more complicated than one would think. When a user pinches to zoom, you need to do two things: (1) scale it by using the distance between two points formula (√(∆x^2 • ∆y^2)) like you said (that's the easy part!), but then also (2) move the image by a certain amount of X and Y offset relative to its container. And it depends on where the user does the pinch. If the user pinches near the bottom of the image to, say, make it bigger, then you need to move the image UP more than if the user pinched near the top, if you can picture why that is (it's not obvious until you get caught up in this). You want the expansion to center around the midpoint of the two touches, not the upper left corner, the lower right corner, or even the center of the image, and that requires scaling -AND- translation. And it gets messy. Read on...

When I said I wanted "pure" translation when the fingers are moving in unison, I really meant I wanted a pure COMPONENT of that, so the two fingers doing a combination of pinching and translating would act a lot like Maps on an iPhone, i.e., you can create any combination of zooming and panning in one single two-finger gesture on the screen. Any two-finger gesture can result in any amount of zooming and panning. They kinda work together.

So somehow I need to calculate Height, Width, XOffset, and YOffset for the image for the (1) translation from unison movement and (2) scaling from the pinching in/out movement components. 

It's an interesting puzzle! (OK, really, it's a Royal pain in the...)

Thanks,

Jack

apple...@mac.com

unread,
Jun 12, 2012, 12:09:05 AM6/12/12
to phon...@googlegroups.com

Devgeeks,

Thanks! I downloaded iScroll and will try it tomorrow!

Jack

Kerri Shotts

unread,
Jun 12, 2012, 1:48:05 AM6/12/12
to phon...@googlegroups.com
Yes, you're right. It's both an interesting puzzle and a royal pain in the ...!

My brain started thinking on the same track as you, and then crashed. There's a core dump laying around somewhere in the living room, I'm sure, waiting to clobber me in the night.

I have the inklings of an idea for how it would work, but for some reason can't quite put it into words or math. Maybe a good night's sleep will work on it. 

I don't know enough about hammer.js to say if it does the translation + zooming at the same time, but it is definitely worth a try...

jcesarmobile

unread,
Jun 12, 2012, 4:32:58 AM6/12/12
to phon...@googlegroups.com
And does iScroll works fine?

I tryed the demo on my ipad 2 and it does strange things

Applejacko

unread,
Jun 14, 2012, 3:38:36 PM6/14/12
to phon...@googlegroups.com
Unfortunately, I haven't tried yet. Got onto other problems I need to solve first. When I try it, I'll post.

Devgeeks

unread,
Jun 15, 2012, 7:47:01 AM6/15/12
to phon...@googlegroups.com
I have an app I never bothered to release that used the iScroll pinch/zoom on an image. It works a treat. 

I have also added it to another app, but I think I might strip it out in favour of a swipe view instead.

jcesarmobile

unread,
Jun 15, 2012, 9:03:09 AM6/15/12
to phon...@googlegroups.com
Sorry Devgeeks, I'm spanish and I don't understand "works a treat", this is good or bad?

I tested the iscroll pinch/zoom demo and after 2 pinchs it becomes unstable, but I didn't try in my own project.

Devgeeks

unread,
Jun 15, 2012, 10:22:23 AM6/15/12
to phon...@googlegroups.com
Haha... sorry. I meant that it worked very well.

I am using it inside a couple of containers...

<div id="mydiv">
<div id="scroller">
<ul id="pix">
<!-- Image to pinch/zoom -->
<li><img src="myImage.png" width="320" /></li> <!-- Image to pinch/zoom -->
</ul>
</div>
</div>

Then I instantiate the iScroll container with something like:

imagePinchZoom = new iScroll(
'pix',
{
zoom: true,
bounce: false,
vScrollbar: false,
hScrollbar: false,
lockDirection: false
}
);

Certainly on iOS this works quite well.

Applejacko

unread,
Jun 28, 2012, 12:30:19 PM6/28/12
to phon...@googlegroups.com

Hi,

I developed my own complete solution to this in JavaScript/HTML, if anyone is interested. 

It does the image move on a single-touch, of course.

And on a two-finger touch, it does the pinch-zoom at the proper point on the image depending on where the fingers are and how they move apart or away relative to each other, and it also moves the image as a whole to the extent the fingers move together relative to the screen. So it gives the complete experience users will expect, i.e., a two-finger gesture will both zoom and move the image. ;-)

Jack

Philipp Austermann

unread,
Jun 28, 2012, 2:22:57 PM6/28/12
to phon...@googlegroups.com, Applejacko
Totally interested, as you know i'm working in a similar area: https://github.com/Philzen/WebView-MultiTouch-Polyfill

Do you have a repository link or such? Really interested to see how you would handle such a gesture in js.

Cheers - Phil
-- You received this message because you are subscribed to the Google
Groups "phonegap" group.
To post to this group, send email to phon...@googlegroups.com
To unsubscribe from this group, send email to
phonegap+u...@googlegroups.com
For more options, visit this group at
http://groups.google.com/group/phonegap?hl=en?hl=en
 
For more info on PhoneGap or to download the code go to www.phonegap.com
 
To compile in the cloud, check out build.phonegap.com


Applejacko

unread,
Jun 28, 2012, 5:40:12 PM6/28/12
to phon...@googlegroups.com, Applejacko

Phil,

Here's the JavaScript and the HTML, for a PhoneGap app. You need to also create an "images" directory under "www" and include an image named "eiffel.jpg" in it that's 200 pixels wide x 300 pixels tall (I used a picture of the Eiffel Tower...) The JS can be tweaked for behaviors like how you want it to act if the user starts with a single touch and then adds a second before lifting their first finger, etc. It uses jQuery Mobile, but it gets those scripts for you over the Internet, and I didn't feel like yanking it out ;-)...

Let me know how it goes.

Jack

================================================== simple.js: ==================================================

var PhoneGapReady = false;

var jQueryReady = false;


var panning = false;

var zooming = false;

var startX0;

var startY0;

var startX1;

var startY1;

var endX0;

var endY0;

var endX1;

var endY1;

var startDistanceBetweenFingers;

var endDistanceBetweenFingers;

var pinchRatio;

var imgWidth = 200;

var imgHeight = 300;


var currentContinuousZoom = 1.0;

var currentOffsetX = -100;

var currentOffsetY = -100;

var currentWidth = imgWidth;

var currentHeight = imgHeight;


var newContinuousZoom;

var newHeight;

var newWidth;

var newOffsetX;

var newOffsetY;


var centerPointStartX;

var centerPointStartY;

var centerPointEndX;

var centerPointEndY;

var translateFromZoomingX;

var translateFromZoomingY;

var translateFromTranslatingX;

var translateFromTranslatingY;

var translateTotalX;

var translateTotalY;


var percentageOfImageAtPinchPointX;

var percentageOfImageAtPinchPointY;


var theImage;


function onBodyLoad()

{

document.addEventListener("deviceready", onDeviceReady, false);

}


/* When this function is called, PhoneGap has been initialized and is ready to roll */

/* If you are supporting your own protocol, the var invokeString will contain any arguments to the app launch.

 see http://iphonedevelopertips.com/cocoa/launching-your-own-application-via-a-custom-url-scheme.html

 for more details -jm */

function onDeviceReady()

{

PhoneGapReady = true;

if (jQueryReady == true

{

PhoneGapAndjQueryReady();

}

}


$(document).bind("mobileinit", function() 

{

// jQuery Mobile framework configuration changes                           

$.support.cors = true;

$.mobile.allowCrossDomainPages = true;

jQueryReady = true;

if (PhoneGapReady == true

{

PhoneGapAndjQueryReady();

}

});


function PhoneGapAndjQueryReady()

{

theImage = document.getElementById("eiffelTower");

theImage.height = imgHeight;

theImage.width = imgWidth;

theImage.style.left = currentOffsetX + "px";

theImage.style.top = currentOffsetY + "px";

theImage.ontouchstart = touchStart;

theImage.ontouchmove = touchMove;

theImage.ontouchend = touchEnd;

theImage.ontouchcancel = touchCancel;

}


function touchStart(event)

{

panning = false;

zooming = false;

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

panning = true;

startX0 = event.touches[0].pageX;

startY0 = event.touches[0].pageY;

}

if (event.touches.length == 2) {

zooming = true;

startX0 = event.touches[0].pageX;

startY0 = event.touches[0].pageY;

startX1 = event.touches[1].pageX;

startY1 = event.touches[1].pageY;

centerPointStartX = ((startX0 + startX1) / 2.0);

centerPointStartY = ((startY0 + startY1) / 2.0);

percentageOfImageAtPinchPointX = (centerPointStartX - currentOffsetX)/currentWidth;

percentageOfImageAtPinchPointY = (centerPointStartY - currentOffsetY)/currentHeight;

startDistanceBetweenFingers = Math.sqrt( Math.pow((startX1-startX0),2) + Math.pow((startY1-startY0),2) );

}

}


function touchMove(event)

{

// This keeps touch events from moving the entire window.

event.preventDefault(); 

if (panning) {

endX0 = event.touches[0].pageX;

endY0 = event.touches[0].pageY;

translateFromTranslatingX = endX0 - startX0;

translateFromTranslatingY = endY0 - startY0;

newOffsetX = currentOffsetX + translateFromTranslatingX;

newOffsetY = currentOffsetY + translateFromTranslatingY;

theImage.style.left = newOffsetX + "px";

theImage.style.top = newOffsetY + "px";

}

else if (zooming) {

// Get the new touches

endX0 = event.touches[0].pageX;

endY0 = event.touches[0].pageY;

endX1 = event.touches[1].pageX;

endY1 = event.touches[1].pageY;


// Calculate current distance between points to get new-to-old pinch ratio and calc width and height

endDistanceBetweenFingers = Math.sqrt( Math.pow((endX1-endX0),2) + Math.pow((endY1-endY0),2) );

pinchRatio = endDistanceBetweenFingers/startDistanceBetweenFingers;

newContinuousZoom = pinchRatio * currentContinuousZoom;

newWidth = imgWidth * newContinuousZoom;

newHeight  = imgHeight * newContinuousZoom;

// Get the point between the two touches, relative to upper-left corner of image

centerPointEndX = ((endX0 + endX1) / 2.0);

centerPointEndY = ((endY0 + endY1) / 2.0);

// This is the translation due to pinch-zooming

translateFromZoomingX = (currentWidth - newWidth) * percentageOfImageAtPinchPointX;

translateFromZoomingY = (currentHeight - newHeight) * percentageOfImageAtPinchPointY;

// And this is the translation due to translation of the centerpoint between the two fingers

translateFromTranslatingX = centerPointEndX - centerPointStartX;

translateFromTranslatingY = centerPointEndY - centerPointStartY;

// Total translation is from two components: (1) changing height and width from zooming and (2) from the two fingers translating in unity

translateTotalX = translateFromZoomingX + translateFromTranslatingX;

translateTotalY = translateFromZoomingY + translateFromTranslatingY;

// the new offset is the old/current one plus the total translation component

newOffsetX = currentOffsetX + translateTotalX;

newOffsetY = currentOffsetY + translateTotalY;

// Set the image attributes on the page

theImage.style.left = newOffsetX + "px";

theImage.style.top = newOffsetY + "px";

theImage.width = newWidth;

theImage.height = newHeight;

}

}


function touchEnd(event)

{

if (panning) {

panning = false;

currentOffsetX = newOffsetX;

currentOffsetY = newOffsetY;

}

else if (zooming) {

zooming = false;

currentOffsetX = newOffsetX;

currentOffsetY = newOffsetY;

currentWidth = newWidth;

currentHeight = newHeight;

currentContinuousZoom = newContinuousZoom;

}

}


function touchCancel(event)

{

alert("cancel fired!");

if (panning) {

panning = false;

}

else if (zooming) {

zooming = false;

}

}


================================================== index.html: ==================================================


<!DOCTYPE html> 

<html> 

<head> 

<title>TM Mobile</title> 

<meta name="viewport" content="width=device-width height=device-height initial-scale=1 user-scaleable=no" /> 

<meta charset="utf-8">

<style type="text/css">

#map_canvas { height: 800px; }

#eiffelTower { position:relative; }

</style>


<link rel="stylesheet" href="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.css" />

<script src="http://code.jquery.com/jquery-1.7.1.min.js"></script>

<script type="text/javascript" charset="utf-8" src="cordova-1.7.0.js"></script>

<script type="text/javascript" charset="utf-8" src="simple.js"></script>

<!-- This script MUST be after $(document).bind("mobileinit", function(), so keep it last here. -->

<script src="http://code.jquery.com/mobile/1.1.0/jquery.mobile-1.1.0.min.js"></script>

</head> 

<body onload="onBodyLoad()">

<div data-role="page" data-fullscreen="true" id="map">

<div data-role="header">

<h4>Jack's</h4>

</div><!-- /header -->

<div data-role="content" id="map_canvas">

<img src='images/eiffel.jpg' id='eiffelTower'/>

</div><!-- /content -->


<div data-role="footer" data-position="fixed" >

<h4>Simple Demo</h4>

</div><!-- /header -->

</div><!-- /page -->

</body>

</html>


Philipp Austermann

unread,
Jun 28, 2012, 6:13:21 PM6/28/12
to phon...@googlegroups.com, Applejacko
thx - a little 80s style publishing it via email ;) ... why don't you commit this in a repository and push it online (github is really cool, but there are many others depending on your favorite SCM)? If your code works well, other developers can easily see it and contribute or even eventually help you transform it into a cordova plugin.

Anyway - I'll be offline for a couple of days, hope i get to play a little bit with it when i get back.

Cheers - Phil

Kerri Shotts

unread,
Jun 28, 2012, 6:23:04 PM6/28/12
to phon...@googlegroups.com, Applejacko
Wow, that's awesome. I'm going to have to spend some time to figure out what you did so that my brain can stop working over the problems inherent in getting pinch-to-zoom to work and feel right. 

Here's a thought -- why not make a gist or pastebin of this? That way you get versioning, and with gist, others could fork it and improve upon it.

Philipp Austermann

unread,
Jun 28, 2012, 7:12:42 PM6/28/12
to phon...@googlegroups.com, Kerri Shotts, Applejacko
Yeah, i forgot to mention gist.github.com, that actually be the quickest way to share for starters or people not (yet) used to git.

Rafael P.

unread,
Jan 13, 2014, 4:50:46 PM1/13/14
to phon...@googlegroups.com, Applejacko
Hi Applejacko,

I just want to say thanks for posting this code.  I was struggling with my own implementation, and going through yours made me pinpoint what I was doing wrong.  After way too many hours put into this, I sincerely thank you!!!

Rafael P.

unread,
Jan 13, 2014, 4:52:28 PM1/13/14
to phon...@googlegroups.com, Applejacko
The cool thing about posting via e-mail is that this communication may (or may not) outlive any other external service used for publishing.  Posting to git or pastebin, sure why not, but a copy in here is still valuable.

So, thanks Applejacko :-)
phonegap+u...@googlegroups.com
For more options, visit this group at
http://groups.google.com/group/phonegap?hl=en?hl=en
 
For more info on PhoneGap or to download the code go to www.phonegap.com
 
To compile in the cloud, check out build.phonegap.com

Applejacko

unread,
Jan 23, 2014, 9:32:36 PM1/23/14
to phon...@googlegroups.com, Applejacko

Rafael P.,

You are very welcome! Glad my struggling with this and having a Eureka! moment could help someone else! 

It's harder than it looks, right? I think everyone starts this with the image resizing correctly, but with it not properly positioned, shooting out like a watermelon seed pinched between two fingers, all over the place. The proper positioning is the hard part to figure out.

It's satisfying to see someone else use your stuff. So thank you too!

Jack
Message has been deleted

Rob Spiess

unread,
Jan 18, 2016, 11:38:00 AM1/18/16
to phonegap, jack.pi...@gmail.com
Here is a simpler version for the pinch-zoom.  Zooming only occurs at the middle of the viewed position instead of between the fingers, but it works well and is much simpler:

<!DOCTYPE html>
<html>
 
<body onload="load_url();">
 
<center>
   URL:
<input type="text" id="theurl" value="https://i.imgur.com/VkeTnfu.jpg">
   
<button id="update_button" onclick="load_url()">Load URL and Reset</button><br><br>
   
<canvas id="mainCanvas" width="144" height="168" style="border:1px solid #d3d3d3;">Your browser does not support the HTML5 canvas tag.</canvas>
 
</center>
 
<script>
   
var image_x = 0, image_y = 0;
   
var zoom = 1;
   
var mouse_x = 0, mouse_y = 0, finger_dist = 0;
   
var source_image_obj = new Image();
   source_image_obj
.addEventListener('load', function() {reset_settings();}, false); // Reset (x,y,zoom) when new image loads

   
function load_url() {
    source_image_obj
.src = document.getElementById("theurl").value;  // load the image
   
}

   
function update_canvas() {
   
var mainCanvas= document.getElementById("mainCanvas");
   
var mainCanvasCTX = document.getElementById("mainCanvas").getContext("2d");
   
var canvas_w = mainCanvas.width, canvas_h = mainCanvas.height; // make things easier to read below
   
// Keep picture in bounds
   
if(image_x - (canvas_w * zoom / 2) > source_image_obj.width ) image_x = source_image_obj.width  + (canvas_w * zoom / 2);
   
if(image_y - (canvas_h * zoom / 2) > source_image_obj.height) image_y = source_image_obj.height + (canvas_h * zoom / 2);
   
if(image_x + (canvas_w * zoom / 2) < 0)                       image_x =                       0 - (canvas_w * zoom / 2);
   
if(image_y + (canvas_h * zoom / 2) < 0)                       image_y =                       0 - (canvas_h * zoom / 2);
   
// Draw the scaled image onto the canvas
    mainCanvasCTX
.clearRect(0, 0, canvas_w, canvas_h);
    mainCanvasCTX
.drawImage(source_image_obj, image_x - (canvas_w * zoom / 2), image_y - (canvas_h * zoom / 2), canvas_w * zoom, canvas_h * zoom, 0, 0, canvas_w, canvas_h);
   
}

   
function reset_settings() {
    image_x
= source_image_obj.width  / 2;
    image_y
= source_image_obj.height / 2;
    zoom
= 1;
    update_canvas
();  // Draw the image in its new position
   
}

   document
.addEventListener('wheel', function(e) {
   
if(e.deltaY<0){
     zoom
= zoom * 1.5;
   
} else {
     zoom
= zoom / 1.5;
   
}
    update_canvas
();
   
}, false);

   document
.addEventListener('mousemove', function(e) {
   
if(e.buttons>0) {
     window
.getSelection().empty();
     image_x
= image_x + zoom * (mouse_x - e.clientX);
     image_y
= image_y + zoom * (mouse_y - e.clientY);
   
}
    mouse_x
= e.clientX; mouse_y = e.clientY; // Save for next time
    update_canvas
(); // draw the image in its new position
   
}, false);

   
function get_distance(e) {
   
var diffX = e.touches[0].clientX - e.touches[1].clientX;
   
var diffY = e.touches[0].clientY - e.touches[1].clientY;
   
return Math.sqrt(diffX * diffX + diffY * diffY); // Pythagorean theorem
   
}

   document
.addEventListener('touchstart', function(e) {
   
if(e.touches.length > 1) { // if multiple touches (pinch zooming)
     finger_dist
= get_distance(e); // Save current finger distance
   
} // Else just moving around
    mouse_x
= e.touches[0].clientX; // Save finger position
    mouse_y
= e.touches[0].clientY; //
   
}, false);

   document
.addEventListener('touchmove', function(e) {
    e
.preventDefault(); // Stop the window from moving
   
if(e.touches.length > 1) { // If pinch-zooming
     
var new_finger_dist = get_distance(e); // Get current distance between fingers
     zoom
= zoom * Math.abs(finger_dist / new_finger_dist); // Zoom is proportional to change
     finger_dist
= new_finger_dist; // Save current distance for next time
   
} else { // Else just moving around
     image_x
= image_x + (zoom * (mouse_x - e.touches[0].clientX)); // Move the image
     image_y
= image_y + (zoom * (mouse_y - e.touches[0].clientY)); //
     mouse_x
= e.touches[0].clientX; // Save finger position for next time
     mouse_y
= e.touches[0].clientY; //
   
}
    update_canvas
(); // draw the new position
   
}, false);

   document
.addEventListener('touchend', function(e) {
    mouse_x
= e.touches[0].clientX; mouse_y = e.touches[0].clientY; // could be down to 1 finger, back to moving image
   
}, false);
 
</script>
 
</body>
</html>

Sment

unread,
Oct 17, 2017, 9:41:04 PM10/17/17
to phonegap
Hi,

How can I update your algo for log scales ?
Reply all
Reply to author
Forward
0 new messages