jnforja
jnforja

Reputation: 1248

HTML5 Canvas Zoom on different points not working

I'm trying to implement zoom in an HTML5 Canvas. I came across other threads that explain how to do it, but they take advantage of the canvas' context, storing previous transformations. I want to avoid that.

So far I managed to do the following (https://jsfiddle.net/wfqzr538/)

<!DOCTYPE html>
<html>

<body style="margin: 0">
  <canvas id="canvas" width="400" height="400" style="border: 1px solid #d3d3d3"></canvas>
  <script>
    const canvas = document.getElementById("canvas");
    const context = canvas.getContext("2d");
    let cursorX = 0,
      cursorY = 0;
    let zoom = 1;

    canvas.onmousemove = mouseMove;
    window.onkeydown = keyDown;

    draw();

    function mouseMove(evt) {
      cursorX = evt.clientX;
      cursorY = evt.clientY;
    }

    function keyDown(evt) {
      if (evt.key == "p") {
        zoom += 0.1;
      }
      if (evt.key == "m") {
        zoom -= 0.1;
      }
      draw();
    }

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

      const translationX = -cursorX * (zoom - 1);
      const translationY = -cursorY * (zoom - 1);
      context.translate(translationX, translationY);
      context.scale(zoom, zoom);

      context.fillRect(100, 100, 30, 30);

      context.scale(1 / zoom, 1 / zoom);
      context.translate(-translationX, -translationY);
    }
  </script>
</body>

</html>

The code above works If I zoom at the same location, but breaks as soon as I change it. For example, if I zoom in twice at the top left corner of the square, it works. However, if I zoom once at the top left corner, followed by zooming at the right bottom corner, it breaks.

I've been trying to fix this for a while now. I think it has something with not taking into account previous translations made in the canvas, but I'm not sure.

I'd really appreciate some help.

Upvotes: 1

Views: 962

Answers (1)

Blindman67
Blindman67

Reputation: 54026

Not keeping previous state

If you don't want to keep the previous transform state then the is no way to do what you are trying to do.

There are many way to keep the previous state

Previous world state

You can transform all object's world state, in effect embedding the previous transform in the object's coordinates.

Eg with zoom and translate

object.x = object.x * zoom + translate.x;
object.y = object.y * zoom + translate.y;
object.w *= zoom;
object.h *= zoom;

then draw using default transform

ctx.setTransform(1,0,0,1,0,0);
ctx.fillRect(object.x, object.y, object.w, object.h);

Previous transformation state

To zoom at a point (absolute pixel coordinate) you need to know where the previous origin was so you can workout how far the zoom point is from that origin and move it correctly when zooming.

Your code does not keep track of the origin, in effect it assumes it is always at 0,0.

Example

The example tracks the previous transform state using an array that represents the transform. It is equivalent to what your code defines as translation and zoom.

  // from your code
  context.translate(translationX, translationY);
  context.scale(zoom, zoom);

  // is 
  transform = [zoom, 0, 0, zoom, translationX, translationY];

The example also changes the rate of zooming. In your code you add and subtract from the zoom, this will result in negative zooms, and when zooming in it will take forever to get to large scales. The scale is apply as a scalar eg zoom *= SCLAE_FACTOR

The function zoomAt zooms in or out at a given point on the canvas

const ctx = canvas.getContext("2d");
const transform = [1,0,0,1,0,0];
const SCALE_FACTOR = 1.1;
const pointer = {x: 0, y: 0};
var zoom = 1;
canvas.addEventListener("mousemove", mouseMove);
addEventListener("keydown", keyDown);
draw();
function mouseMove(evt) {
    pointer.x = evt.clientX;
    pointer.y = evt.clientY;
    drawPointer();
}
function keyDown(evt) {
    if (evt.key === "p") { zoomAt(SCALE_FACTOR, pointer) }
    if (evt.key === "m") { zoomAt(1 / SCALE_FACTOR, pointer) }
}
function zoomAt(amount, point) {
    zoom *= amount;
    transform[0] = zoom;  // scale x
    transform[3] = zoom;  // scale y
    transform[4] = point.x - (point.x - transform[4]) * amount;
    transform[5] = point.y - (point.y - transform[5]) * amount;
    draw();
}
function draw() {
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.setTransform(...transform);
    ctx.fillRect(100, 100, 30, 30);
}
function drawPointer() {
    draw();
    ctx.lineWidth = 1;
    ctx.strokeStyle = "black";
    const invScale = 1 / transform[0]; // Assumes uniform scale
    const x = (pointer.x - transform[4]) * invScale;
    const y = (pointer.y - transform[5]) * invScale;
    const size = 10 * invScale;
    ctx.beginPath();
    ctx.lineTo(x - size, y);
    ctx.lineTo(x + size, y);
    ctx.moveTo(x, y - size);
    ctx.lineTo(x, y + size);
    ctx.setTransform(1, 0, 0, 1, 0, 0); // to ensure line width is 1 px
    ctx.stroke();
    ctx.font = "16px arial";
    ctx.fillText("Pointer X: " + x.toFixed(2) + "  Y: " + y.toFixed(2), 10, 20);
}
* {margin: 0px}
canvas { border: 1px solid #aaa }
<canvas id="canvas" width="400" height="400"></canvas>

Update

I have added the function drawPointer which uses the transform to calculate the pointers world position, render a cross hair at the position and display the coordinates.

Upvotes: 2

Related Questions