16

I'm trying to create zoom effect on canvas, and I've managed to do that, but there's a small problem. Zooming (scaling) origin is top left of the canvas. How can I specify a zoom/scale origin?

I suppose I need to use translate but I don't know how and where I should implement it.

What I want to use as zoom origin is the mouse position, but for simplicity, center of canvas will do.

JSFiddle

var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
canvas.width = 600;
canvas.height = 400;

var global = {
  zoom: {
    origin: {
      x: null,
      y: null,
    },
    scale: 1,
  },
};

function zoomed(number) {
  return Math.floor(number * global.zoom.scale);
}

function draw() {
  context.beginPath();
  context.rect(zoomed(50), zoomed(50), zoomed(100), zoomed(100));
  context.fillStyle = 'skyblue';
  context.fill();

  context.beginPath();
  context.arc(zoomed(350), zoomed(250), zoomed(50), 0, 2 * Math.PI, false);
  context.fillStyle = 'green';
  context.fill();
}

draw();

canvas.addEventListener("wheel", trackWheel);
canvas.addEventListener("wheel", zoom);

function zoom() {
  context.setTransform(1, 0, 0, 1, 0, 0);
  context.clearRect(0, 0, canvas.width, canvas.height);
  draw();
}

function trackWheel(e) {
  if (e.deltaY < 0) {
    if (global.zoom.scale < 5) {
      global.zoom.scale *= 1.1;
    }
  } else {
    if (global.zoom.scale > 0.1) {
      global.zoom.scale *= 0.9;
    }
  }
  global.zoom.scale = parseFloat(global.zoom.scale.toFixed(2));
}
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>


Update 1

It seems there are few other question related to this subject on SO, but none that I can directly implement in my code.

I've tried to examine the demo Phrogz provided in Zoom Canvas to Mouse Cursor, but it's far too complex (for me, at least). Tried to implement his solution:

ctx.translate(pt.x,pt.y);
ctx.scale(factor,factor);
ctx.translate(-pt.x,-pt.y);

JSFiddle

var canvas = document.getElementById("canvas");
var context = canvas.getContext("2d");
canvas.width = 600;
canvas.height = 400;

var global = {
  zoom: {
    origin: {
      x: null,
      y: null,
    },
    scale: 1,
  },
};

function draw() {
  context.beginPath();
  context.rect(50, 50, 100, 100);
  context.fillStyle = 'skyblue';
  context.fill();

  context.beginPath();
  context.arc(350, 250, 50, 0, 2 * Math.PI, false);
  context.fillStyle = 'green';
  context.fill();
}

draw();

canvas.addEventListener("wheel", trackWheel);
canvas.addEventListener("wheel", trackMouse);
canvas.addEventListener("wheel", zoom);

function zoom() {
  context.setTransform(1, 0, 0, 1, 0, 0);
  context.clearRect(0, 0, canvas.width, canvas.height);

  context.translate(global.zoom.origin.x, global.zoom.origin.y);
  context.scale(global.zoom.scale, global.zoom.scale);
  context.translate(-global.zoom.origin.x, -global.zoom.origin.y);

  draw();
}

function trackWheel(e) {
  if (e.deltaY > 0) {
    if (global.zoom.scale > 0.1) {
      global.zoom.scale *= 0.9;
    }
  } else {
    if (global.zoom.scale < 5) {
      global.zoom.scale *= 1.1;
    }
  }
  global.zoom.scale = parseFloat(global.zoom.scale.toFixed(2));
}

function trackMouse(e) {
  global.zoom.origin.x = e.clientX;
  global.zoom.origin.y = e.clientY;
}
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

but it didn't really help. It seems to use the mouse position as the zoom origin but there are "jumps" when I zoom in.


Update 2

I've managed to isolate and simplify the zoom effect from Blindman67's example to understand how it works better. I gotta admit, I still don't fully understand it :) I'm gonna share it here. Future visitors might benefit.

JSFiddle

var canvas    = document.getElementById("canvas");
var context   = canvas.getContext("2d");
canvas.width  = 600;
canvas.height = 400;

var zoom = {
  scale : 1,
  screen : {
    x : 0,
    y : 0,
  },
  world : {
    x : 0,
    y : 0,
  },
};

var mouse = {
  screen : {
    x : 0,
    y : 0,
  },
  world : {
    x : 0,
    y : 0,
  },
};

var scale = {
  length : function(number) {
    return Math.floor(number * zoom.scale);
  },
  x : function(number) {
    return Math.floor((number - zoom.world.x) * zoom.scale + zoom.screen.x);
  },
  y : function(number) {
    return Math.floor((number - zoom.world.y) * zoom.scale + zoom.screen.y);
  },
  x_INV : function(number) {
    return Math.floor((number - zoom.screen.x) * (1 / zoom.scale) + zoom.world.x);
  },
  y_INV : function(number) {
    return Math.floor((number - zoom.screen.y) * (1 / zoom.scale) + zoom.world.y);
  },
};

function draw() {
  context.clearRect(0, 0, canvas.width, canvas.height);

  context.beginPath();
  context.rect(scale.x(50), scale.y(50), scale.length(100), scale.length(100));
  context.fillStyle = 'skyblue';
  context.fill();

  context.beginPath();
  context.arc(scale.x(350), scale.y(250), scale.length(50), 0, 2 * Math.PI, false);
  context.fillStyle = 'green';
  context.fill();
}

canvas.addEventListener("wheel", zoomUsingCustomScale);

function zoomUsingCustomScale(e) {
  trackMouse(e);
  trackWheel(e);
  scaleShapes();
}

function trackMouse(e) {
  mouse.screen.x = e.clientX;
  mouse.screen.y = e.clientY;
  mouse.world.x  = scale.x_INV(mouse.screen.x);
  mouse.world.y  = scale.y_INV(mouse.screen.y);
}

function trackWheel(e) {
  if (e.deltaY < 0) {
    zoom.scale = Math.min(5, zoom.scale * 1.1);
  } else {
    zoom.scale = Math.max(0.1, zoom.scale * (1/1.1));
  }
}

function scaleShapes() {
  zoom.screen.x = mouse.screen.x;
  zoom.screen.y = mouse.screen.y;
  zoom.world.x = mouse.world.x;
  zoom.world.y = mouse.world.y;
  mouse.world.x = scale.x_INV(mouse.screen.x);
  mouse.world.y = scale.y_INV(mouse.screen.y);
  draw();
}

draw();
body {
  background: gainsboro;
  margin: 0;
}

canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

Since this is a simplified version, I suggest you check out the Blindman67's example first. Also, even though I've accepted Blindman67's answer, you can still post an answer. I find this subject interesting. So I'd like to know more about it.

Community
  • 1
  • 1
akinuri
  • 10,690
  • 10
  • 65
  • 102
  • Take a look at this previous answer [here](http://stackoverflow.com/a/5526721/4512046). – Monica Olejniczak Aug 07 '16 at 02:40
  • @MonicaOlejniczak Yes, seen that already. I was updating the question when you commented. His demo is too complex, and couldn't implement his solution perfectly. – akinuri Aug 07 '16 at 02:46
  • When zooming in/out you will get better performance if you cache your canvas to a second in-memory canvas and use that second canvas as a source to draw onto the main canvas. When not zooming you directly use the main canvas for drawing. Here is a previous [Q&A](http://stackoverflow.com/questions/27339747/zoom-and-pan-in-animated-html5-canvas/27340533#27340533) showing how to zoom into the mouse position. – markE Aug 07 '16 at 03:36
  • @markE Will that still work with animated canvas? I'll be zooming a canvas animation eventually. Just experimenting atm. The updated code seems to zoom in/out fine as long as the zoom origin is fixed. Slight changes in zoom origin (mouse pointer position) while zooming causes "jumps". Phrogz's demo doesn't seem to have that problem. – akinuri Aug 07 '16 at 03:49
  • Zooming fixed canvas content (using a second in-memory canvas), yes. Zooming constantly changing canvas content -- who knows, you'll have to test your app to see how it behaves. – markE Aug 07 '16 at 04:09

1 Answers1

28

If it is only zoom and pan then the solution is simple.

You need to track two origins. One is the position of the mouse in the world coordinates (box and circle position) and the other is the position of the mouse in the screen coordinates (canvas pixels)

You will need to get used to converting from one coordinate system to the other. That is done via the inverse function. From world coords to screen coords can be reversed with the inverse function that will convert from screen coords to world coords.

Examples of inverting some simple functions

  • 2 * 10 = 20 the inverse is 20 / 10 = 2
  • 2 + 3 = 5 the inverse is 5 - 3 = 2
  • (3 - 1) * 5 = 10 the inverse is 10 * (1/5) + 1 = 3

Multiply becomes * 1 over. ie x*5 becomes x * 1/5 (or just x/5) Adds become subtracts and subtract become add and what is first become last and last becomes first (3 - first) * last = result the inverse is result / last + first = 3

So you zoom a coordinate (world coord position of box) and get the screen position of box in pixels. If you want the world coords of a screen pixel you apply the inverse

It's all mouthful so here is your code doing what you need with some comments and I added mousemove, button stuff and other stuff because you need the mouse pos and no point in zooming if you can not pan, need to stop mouse button locking and stop wheel scrolling blah blah... To pan just move the world origin (in code) click drag in UI. Also I am lazy and got rid of the global.zoom.origin.x stuff now scale is well you know, and wx,wy,sx,sy are origins read code for what is what.

var canvas    = document.getElementById("canvas");
var context   = canvas.getContext("2d");
canvas.width  = 600;
canvas.height = 400;

// lazy programmers globals
var scale = 1;
var wx    = 0; // world zoom origin
var wy    = 0;
var sx    = 0; // mouse screen pos
var sy    = 0;

var mouse = {};
mouse.x   = 0; // pixel pos of mouse
mouse.y   = 0;
mouse.rx  = 0; // mouse real (world) pos
mouse.ry  = 0;
mouse.button = 0;

function zoomed(number) { // just scale
  return Math.floor(number * scale);
}
// converts from world coord to screen pixel coord
function zoomedX(number) { // scale & origin X
  return Math.floor((number - wx) * scale + sx);
}

function zoomedY(number) { // scale & origin Y
  return Math.floor((number - wy) * scale + sy);
}

// Inverse does the reverse of a calculation. Like (3 - 1) * 5 = 10   the inverse is 10 * (1/5) + 1 = 3
// multiply become 1 over ie *5 becomes * 1/5  (or just /5)
// Adds become subtracts and subtract become add.
// and what is first become last and the other way round.

// inverse function converts from screen pixel coord to world coord
function zoomedX_INV(number) { // scale & origin INV
  return Math.floor((number - sx) * (1 / scale) + wx);
  // or return Math.floor((number - sx) / scale + wx);
}

function zoomedY_INV(number) { // scale & origin INV
  return Math.floor((number - sy) * (1 / scale) + wy);
  // or return Math.floor((number - sy) / scale + wy);
}

// draw everything in pixels coords
function draw() {
  context.clearRect(0, 0, canvas.width, canvas.height);
  
  context.beginPath();
  context.rect(zoomedX(50), zoomedY(50), zoomed(100), zoomed(100));
  context.fillStyle = 'skyblue';
  context.fill();

  context.beginPath();
  context.arc(zoomedX(350), zoomedY(250), zoomed(50), 0, 2 * Math.PI, false);
  context.fillStyle = 'green';
  context.fill();
}
// wheel event must not be passive to allow default action to be prevented
canvas.addEventListener("wheel", trackWheel, {passive:false}); 
canvas.addEventListener("mousemove", move)
canvas.addEventListener("mousedown", move)
canvas.addEventListener("mouseup", move)
canvas.addEventListener("mouseout", move) // to stop mouse button locking up 

function move(event) { // mouse move event
  if (event.type === "mousedown") {
    mouse.button = 1;
  }
  else if (event.type === "mouseup" || event.type === "mouseout") {
    mouse.button = 0;
  }

  mouse.bounds = canvas.getBoundingClientRect();
  mouse.x = event.clientX - mouse.bounds.left;
  mouse.y = event.clientY - mouse.bounds.top;
  var xx  = mouse.rx; // get last real world pos of mouse
  var yy  = mouse.ry;

  mouse.rx = zoomedX_INV(mouse.x); // get the mouse real world pos via inverse scale and translate
  mouse.ry = zoomedY_INV(mouse.y);
  if (mouse.button === 1) { // is mouse button down 
    wx -= mouse.rx - xx; // move the world origin by the distance 
    // moved in world coords
    wy -= mouse.ry - yy;
    // recaculate mouse world 
    mouse.rx = zoomedX_INV(mouse.x);
    mouse.ry = zoomedY_INV(mouse.y);
  }
  draw();
}

function trackWheel(e) {
  
  if (e.deltaY < 0) {
    scale = Math.min(5, scale * 1.1); // zoom in
  } else {
    scale = Math.max(0.1, scale * (1 / 1.1)); // zoom out is inverse of zoom in
  }
  wx = mouse.rx; // set world origin
  wy = mouse.ry;
  sx = mouse.x; // set screen origin
  sy = mouse.y;
  mouse.rx = zoomedX_INV(mouse.x); // recalc mouse world (real) pos
  mouse.ry = zoomedY_INV(mouse.y);
  
  draw();
  e.preventDefault(); // stop the page scrolling
}
draw();
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • I love this. Thank you. I was already done with pan effect but you've combined two of them, which is great. Upvoted, but first, let me process the code :) – akinuri Aug 07 '16 at 11:47
  • I've [isolated](https://jsfiddle.net/akinuri/8f78hrtm/) the pan effect, and I think, I'm beginning to understand how it works. However, I have a question. Can this be accomplished using `translate` and `scale` like in Phrogz's [demo](http://phrogz.net/tmp/canvas_zoom_to_cursor.html), instead of scaling `x, y, width, height` of drawn objects? – akinuri Aug 07 '16 at 13:09
  • @akinuri It may pay to get a handle on this one first. You can do it using matrices, and the process is much the same. Matrices just provide some short cuts to the math involved. This example http://stackoverflow.com/a/38676266/3877726 show a more sophisticated version.(give the image time to load its slow sometimes) There is as a pure Matrix math only method, but I have not yet posted anything like that here. – Blindman67 Aug 07 '16 at 13:41
  • Hello Blindman67. I hope it okay if i hijack this comment section. I have been working on a game for months now and i huge troubles with a zoom function. Or, to be more specific, my problem is: how do i keep track on an objects position that has been zoomed (i.e. draw object origin x/y on a canvas, zoom around -> i cant check the mousemove event x/y versus the object anymore, because the offset is not okay anymore. Please take a look at this complete codepen, i would help me so much. Spend so many hours yet....http://codepen.io/AncientSion/pen/zZNqBr – user431806 Mar 08 '17 at 19:40
  • @Blindman67, which one is the fastest option? `translate` and `scale`, matrices or individually zoom items of the canvas? – Hristo Enev May 21 '17 at 08:52
  • @HristoEnev Much faster using the transform matrix than calculating coordinates as you draw. The matrix is applied by default so setting the matrix to some thing else will not effect performance (apart from the fact that zooming in will draw more pixels and is thus slower) This is the most current answer I have added on the subject http://stackoverflow.com/a/44015705/3877726 – Blindman67 May 21 '17 at 10:14