akinuri akinuri - 3 months ago 23
Javascript Question

Set canvas zoom/scale origin

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.

Answer

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();
}

canvas.addEventListener("wheel", trackWheel);
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);
  event.preventDefault(); // stop the page scrolling
  draw();
}
draw();
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

Comments