Set canvas zoom/scale origin
<p>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?</p>

<p>I suppose I need to use <code>translate</code> but I don't know how and where I should implement it.</p>

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

<p><a href="https://jsfiddle.net/akinuri/byhnb65j/" rel="noreferrer">JSFiddle</a></p>

<p><div class="snippet" data-lang="js" data-hide="true" data-console="true" data-babel="false">
<div class="snippet-code snippet-currently-hidden">
<pre class="snippet-code-js lang-js prettyprint-override"><code>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 &lt; 0) {
    if (global.zoom.scale &lt; 5) {
      global.zoom.scale *= 1.1;
    }
  } else {
    if (global.zoom.scale &gt; 0.1) {
      global.zoom.scale *= 0.9;
    }
  }
  global.zoom.scale = parseFloat(global.zoom.scale.toFixed(2));
}</code></pre>
<pre class="snippet-code-css lang-css prettyprint-override"><code>body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}</code></pre>
<pre class="snippet-code-html lang-html prettyprint-override"><code>&lt;canvas id="canvas"&gt;&lt;/canvas&gt;</code></pre>
</div>
</div>
</p>

<p><hr>
<strong>Update 1</strong></p>

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

<p>I've tried to examine the <a href="http://phrogz.net/tmp/canvas_zoom_to_cursor.html" rel="noreferrer">demo</a> Phrogz provided in <a href="https://stackoverflow.com/questions/5189968/zoom-canvas-to-mouse-cursor">Zoom Canvas to Mouse Cursor</a>, but it's far too complex (for me, at least). Tried to implement his solution:</p>

<pre><code>ctx.translate(pt.x,pt.y);
ctx.scale(factor,factor);
ctx.translate(-pt.x,-pt.y);
</code></pre>

<p><a href="https://jsfiddle.net/akinuri/byhnb65j/1/" rel="noreferrer">JSFiddle</a></p>

<p><div class="snippet" data-lang="js" data-hide="true" data-console="true" data-babel="false">
<div class="snippet-code snippet-currently-hidden">
<pre class="snippet-code-js lang-js prettyprint-override"><code>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 &gt; 0) {
    if (global.zoom.scale &gt; 0.1) {
      global.zoom.scale *= 0.9;
    }
  } else {
    if (global.zoom.scale &lt; 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;
}</code></pre>
<pre class="snippet-code-css lang-css prettyprint-override"><code>body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}</code></pre>
<pre class="snippet-code-html lang-html prettyprint-override"><code>&lt;canvas id="canvas"&gt;&lt;/canvas&gt;</code></pre>
</div>
</div>
</p>

<p>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.</p>

<hr>

<p><strong>Update 2</strong></p>

<p>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.</p>

<p><a href="https://jsfiddle.net/akinuri/cbx7pmra/" rel="noreferrer">JSFiddle</a></p>

<p><div class="snippet" data-lang="js" data-hide="true" data-console="true" data-babel="false">
<div class="snippet-code snippet-currently-hidden">
<pre class="snippet-code-js lang-js prettyprint-override"><code>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 &lt; 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();</code></pre>
<pre class="snippet-code-css lang-css prettyprint-override"><code>body {
  background: gainsboro;
  margin: 0;
}

canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}</code></pre>
<pre class="snippet-code-html lang-html prettyprint-override"><code>&lt;canvas id="canvas"&gt;&lt;/canvas&gt;</code></pre>
</div>
</div>
</p>

<p>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.</p>