Zoom and pan in animated HTML5 canvas

I have a map. I want a user to be able to zoom and pan the map. Imagine Google Maps, but instead of being infinitely pannable, the map is a square (it doesn't wrap around again if you go past the edge of it).

I have implemented zoom and pan using scale() and translate(). These work well.

I am stuck on the final part - when a user zooms, I want to center the zoom around that point. It is hard to explain in words, so just imagine what happens when you mousewheel in Google Maps - that is what I want.

I have looked at every answer on SO with any of these terms in the title. Most are variations on this one, which basically say this is what I need to do:

ctx.translate(/* to the point where the mouse is */);
ctx.scale(/* to zoom level I want */)
ctx.translate(/* back to the point where the mouse was, taking zoom into account */);

However, no matter what I do, I cannot seem to get it to work. I can get it to zoom to a particular point after zooming, but whatever I do I cannot make that point equal to where the mouse pointer was.

Check out this fiddle. Imagine the square is a map and the circles are countries or whatever.

The best implementation I have found is this SO answer and the linked example. However, the code makes use of SVG and .createSVGMatrix() and all sorts of things that, frankly, I can't understand. I would prefer a totally-canvas solution if possible.

Obviously I am not interested in doing this with a library. I want to understand why what I'm doing is not working.


Solution 1:

Here's one technique for zooming at a point:

Drawing the map

Simplify things by not using transforms to draw the map (no need for translate,scale!).

All that's needed is the scaling version of context.drawImage.

What you do is scale the original map to the desired size and then pull it upward and leftward from the scaling point that the user has selected.

context.drawImage(
    map,
    0,0,map.width,map.height,  // start with the map at original (unscaled) size
    offsetX,offsetY,           // pull the map leftward & upward from the scaling point
    scaledWidth,scaledHeight   // resize the map to the currently scaled size

Selecting the scaling point (the focal point):

The scaling focal point is actually 2 points!

The first focal point is the mouseX,mouseY where the user clicked to set their desired scaling point. It's important to remember that the mouse coordinate is in scaled space. The map that the user is seeing/clicking is scaled so their mouseX,mouseY is scaled also.

The second focal point is calculated by unscaling the mouse coordinate. This second point is the equivalent mouse position on the original unscaled map.

The second unscaled focal point is used to calculate how much to pull the scaled map leftward and upward from the first focal point.

function setFocus(mx,my){
    // mouseX,mouseY is the scaling point in scaled coordinates
    focusX=mx;
    focusY=my;
    // convert the scaled focal point
    // to an unscaled focal point
    focusX1=parseInt((mx-mapLeft)/scale);
    focusY1=parseInt((my-mapTop)/scale);
}

Scaling the map

When the user indicates they want to scale the map larger or smaller:

  • calculate the new scaled map width & height
  • calculate how much offset is needed to pull the newly scaled map upward and leftward from the scaling point (the scaling point was previously selected by the mouse position).

Code:

function setScale(newScale){
    scale=newScale;
    // calc the width & height of the newly scaled map
    mapWidth=parseInt(iw*scale);
    mapHeight=parseInt(ih*scale);
    // calc how much to offset the map on the canvas
    mapLeft=parseInt(focusX-focusX1*scale);
    mapTop =parseInt(focusY-focusY1*scale);
    // draw the map
    drawMap();
}

Here's example code and a Demo:

var canvas=document.getElementById("canvas");
var ctx=canvas.getContext("2d");
var $canvas=$("#canvas");
var canvasOffset=$canvas.offset();
var offsetX=canvasOffset.left;
var offsetY=canvasOffset.top;

//
var counter=1;
var PI2=Math.PI*2;
var iw,ih;
var mapLeft,mapTop,mapWidth,mapHeight;
var focusX,focusY,focusX1,focusY1;
var scale;

var map=new Image();
map.onload=start;
map.src="https://dl.dropboxusercontent.com/u/139992952/multple/mapSmall.png";
function start(){

  iw=map.width;
  ih=map.height;

  // initial 
  mapLeft=0;
  mapTop=0;
  scale=1.00;

  setFocus(iw/2*scale,ih/2*scale);

  setScale(scale);   // also sets mapWidth,mapHeight

  drawMap();

  //
  $("#canvas").mousedown(function(e){handleMouseDown(e);});

  //
  canvas.addEventListener('DOMMouseScroll',handleScroll,false);
  canvas.addEventListener('mousewheel',handleScroll,false);
}

//
function setScale(newScale){
  scale=newScale;
  mapWidth=parseInt(iw*scale);
  mapHeight=parseInt(ih*scale);    
  mapLeft=parseInt(focusX-focusX1*scale);
  mapTop =parseInt(focusY-focusY1*scale);
  drawMap();
}

//
function setFocus(mx,my){
  // mouseX,mouseY is the scaling point in scaled coordinates
  focusX=mx;
  focusY=my;
  // convert the scaled focal point
  // to an unscaled focal point
  focusX1=parseInt((mx-mapLeft)/scale);
  focusY1=parseInt((my-mapTop)/scale);
  //
  drawMap();
}

//
function drawMap(){
  ctx.clearRect(0,0,canvas.width,canvas.height);
  ctx.save();
  ctx.drawImage(map,0,0,iw,ih,mapLeft,mapTop,mapWidth,mapHeight);
  dot(ctx,focusX,focusY,"red");
  ctx.restore();
}

function dot(ctx,x,y,fill){
  ctx.beginPath();
  ctx.arc(x,y,4,0,PI2);
  ctx.closePath();
  ctx.fillStyle=fill;
  ctx.fill();
  ctx.lineWidth=2;
  ctx.stroke();
}

//
function handleScroll(e){
  e.preventDefault();
  e.stopPropagation();

  var delta=e.wheelDelta?e.wheelDelta/30:e.detail?-e.detail:0;
  if (delta){
    counter+=delta;
    setScale(1+counter/100);
  }
};

//
function handleMouseDown(e){
  e.preventDefault();
  e.stopPropagation();
  mouseX=parseInt(e.clientX-offsetX);
  mouseY=parseInt(e.clientY-offsetY);
  setFocus(mouseX,mouseY);
  drawMap();
}
body{ background-color: ivory; }
canvas{border:1px solid red;}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<h4>Click to set zoom point<br>Use mousewheel to zoom</h4>
<canvas id="canvas" width=600 height=400></canvas><br>