5

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.

Community
  • 1
  • 1
David John Welsh
  • 1,564
  • 1
  • 14
  • 23
  • 1
    I did something similar recently, hope it's relevant http://stackoverflow.com/questions/20942586/controlling-the-pan-to-anchor-a-point-when-zooming-into-an-image (without your code, I think it's probably a mathematical error in the 'back translation' (you must scale the point too)) – Anti Earth Dec 07 '14 at 04:55
  • 1
    Oh, that looks interesting! Thanks, I'll have a look at it now. Can't believe I missed that question. I guess I shouldn't have searched only for "canvas" solutions. – David John Welsh Dec 07 '14 at 04:58

1 Answers1

6

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>
markE
  • 102,905
  • 11
  • 164
  • 176
  • This is a pretty cool approach, but I'm thinking it can't apply to my situation. My map is not an image, but is drawn dynamically using canvas (it's animated, for one thing, so this is non-negotiable). In retrospect perhaps the "like Google Maps" thing was a bit of a red herring, since I'm not using images :-/ – David John Welsh Dec 07 '14 at 08:29
  • This approach is adaptable to a dynamically drawn canvas. Just create a second in-memory canvas on which to dynamically draw your map. Then instead of `drawImage(image...` you can `drawImage(myDynamicCanvas...`. This works because the second dynamic canvas can be an image source for drawImage on the visible canvas. Good luck with your project! – markE Dec 07 '14 at 16:07
  • Ohh... That's a really good idea! Never thought about a second canvas. I'll give it a shot! – David John Welsh Dec 08 '14 at 06:17
  • That hardly seems necessary for what is ultimately a simple mathematical procedure. Is there anything that confuses you in the method I've outlined? – Anti Earth Dec 08 '14 at 06:57
  • @AntiEarth Not at all! At least, I don't think so. I have only looked at both methods briefly so far; I haven't had time to implement anything yet. I'm finally free to work on this now, so I'm going to get into more detail and see which applies better for my problem. My actual project is a lot more complex than the fiddle I made - it includes rotation, for one thing - so I'm not sure of anything yet. Rest assured I'm going to consider everything. – David John Welsh Dec 08 '14 at 10:08