Zoom Canvas to Mouse Cursor

Zoom canvas to mouse cursor without ctx.scale

The explanation is with the code:

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// utils //
function getCursorPos(evt) {
const rect = canvas.getBoundingClientRect();
return {
x: Math.floor(((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas.offsetWidth),
y: Math.floor(((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas.offsetHeight),
};
}
//////////

const scene = {
renderer: canvas,
context: ctx,
width: 1200,
height: 1000,
cellSize: 30,
render: function (buffer, x, y) {
this.context.clearRect(0, 0, this.renderer.width, this.renderer.height);
this.context.drawImage(buffer, x, y);
},
};

class Grid {
constructor() {
this.width = scene.width;
this.height = scene.height;
this.cellSize = scene.cellSize;
this.color = "black";
this.buffer = document.createElement("canvas");
this.buffer.width = this.width;
this.buffer.height = this.height;
}

build() {
// we don't directly make the draw calls on the main canvas (scene.renderer) ,
// instead we create a buffer (a canvas element in this case),
// which will be drawn as an image on the main canvas when we call scene.render();
const ctx = this.buffer.getContext("2d");
ctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
ctx.setLineDash([2, 5]);

for (let u = 0, len = this.height; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(0.5, u + 0.5);
ctx.lineTo(0.5 + this.width, u + 0.5);
ctx.stroke();
}

for (let u = 0, len = this.width; u < len; u += this.cellSize) {
ctx.beginPath();
ctx.moveTo(u + 0.5, 0.5);
ctx.lineTo(u + 0.5, 0.5 + this.height);
ctx.stroke();
}
}

setDimensions(w, h) {
this.buffer.width = this.width = w; // GT
this.buffer.height = this.height = h; // GT
}

getDimensions() {
return { gw: this.width, gh: this.height };
}

setCellSize(size) {
this.cellSize = size;
}

getCellSize() {
return this.cellSize;
}

getBuffer() {
return this.buffer;
}
}

class Camera {
constructor() {
this.x = 0;
this.y = 0;
this.startDrag = null;
this.zoom = 1;
this.zoomInc = 0.05;
}

// converts screen coordinates to world coordinates
toWorld(number) {
return Math.floor(number / this.zoom);
}

toScreen(number) {
return Math.floor(number / this.zoom);
}

setStartDrag(coord) {
this.startDrag = { x: this.x + coord.x, y: this.y + coord.y };
}

isStartedDrag() {
return !!this.startDrag;
}

drag(coord) {
this.x = this.startDrag.x - coord.x;
this.y = this.startDrag.y - coord.y;
}

stopDrag() {
this.startDrag = null;
}

// the bit of code I can't figure //
setScale({ x, y, deltaY }) {
const step = deltaY > 0 ? -this.zoomInc : this.zoomInc;
if (this.zoom + step <= 0) return // for extra credit ;)
// Fix x,y:
x -= canvas.offsetLeft
y -= canvas.offsetTop
const zoom = this.zoom // old zoom
this.zoom += step;
/* We want in-world coordinates to remain the same:
* (x + this.x')/this.zoom = (x + this.x)/zoom
* (y + this.y')/this.zoom = (y + this.y)/zoom
* =>
*/
this.x = (x + this.x)*this.zoom/zoom - x
this.y = (y + this.y)*this.zoom/zoom - y

// this.x and this.y is where the grid is going to be rendered on the canvas;

// first I thought about doing it this way :
//this.x = -this.toScreen(this.toWorld(x) - x);
//this.y = -this.toScreen(this.toWorld(y) - y);
// but it only work if the grid is at x: 0 y: 0;

// after some research I tried to shift x and y relatively to the cursor world position in the grid;
//const worldPos = { x: this.toWorld(x) - this.x, y: this.toWorld(y) - this.y };
//this.x = -(this.x - worldPos.x * step);
//this.y = -(this.y - worldPos.y * step);

// if x and y aren't changed the zoom origin defaults to the current origin of the camera;
}

getZoom() {
return this.zoom;
}
}

function init() {
// initial setup //
const grid = new Grid();
const camera = new Camera();
grid.build();
const gridBuffer = grid.getBuffer();
scene.context.drawImage(gridBuffer, 0, 0);

scene.renderer.addEventListener("mousemove", (evt) => {
if (camera.isStartedDrag()) {
camera.drag(getCursorPos(evt));
scene.render(gridBuffer, -camera.x, -camera.y);
}
});

scene.renderer.addEventListener("mousedown", (evt) => {
camera.setStartDrag(getCursorPos(evt));
});

scene.renderer.addEventListener("mouseup", () => {
camera.stopDrag();
});

scene.renderer.addEventListener("wheel", (evt) => {
evt.preventDefault();
camera.setScale(evt);
const zoom = camera.getZoom();
grid.setCellSize(scene.cellSize * zoom);
grid.setDimensions(scene.width * zoom, scene.height * zoom);

// we rebuild a smaller or bigger grid according to the new zoom level;
grid.build();
const gridBuffer = grid.getBuffer();
scene.render(gridBuffer, -camera.x, -camera.y);
});
}

init();
    <html lang="en">
<head>
<script defer src="main.js"></script>
</head>
<body>
<canvas id="canvas" width="800" height="600" style="border: 1px solid black"></canvas>
</body>
</html>

HTML Canvas - Zooming in and out in exactly on the mouse pointer using the mouse wheel - Check codepen example

Ok, I could find a solution.
It consist basically to track all the transformations and save it as inverted matrix.
So, now the really correct position of the mouse is calculated regards to the transformations.
Please, check the solution here.

window.onload = function () {
const img = document.querySelector("img");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
trackTransforms(ctx);

let zoom = 1;
let mousePos = {
x: 0,
y: 0
};

draw();
document.body.addEventListener("mousemove", (e) => {
const canvas = document.querySelector("canvas");
const rect = canvas.getBoundingClientRect();
mousePos.x = e.clientX - rect.left;
mousePos.y = e.clientY - rect.top;
});

document.getElementById("canvas").addEventListener("wheel", (e) => {
e.preventDefault();
zoom = e.deltaY < 0 ? 1.1 : 0.9;
scaleDraw();
draw();
});

function draw() {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}

function scaleDraw() {
const pt = ctx.transformedPoint(mousePos.x, mousePos.y);
console.log("PT: " + `${pt.x}, ${pt.y}`);
console.log("MOUSEPOINTER: " + `${mousePos.x}, ${mousePos.y}`);
ctx.translate(pt.x, pt.y);
ctx.scale(zoom, zoom);
ctx.translate(-pt.x, -pt.y);
}
};

function trackTransforms(ctx) {
debugger;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
let xform = svg.createSVGMatrix();

const savedTransforms = [];
let save = ctx.save;
ctx.save = () => {
savedTransforms.push(xform.translate(0, 0));
return save.call(ctx);
};

let restore = ctx.restore;
ctx.restore = () => {
xform = savedTransforms.pop();
return restore.call(ctx);
};

let scale = ctx.scale;
ctx.scale = (sx, sy) => {
xform = xform.scaleNonUniform(sx, sy);
return scale.call(ctx, sx, sy);
};

let translate = ctx.translate;
ctx.translate = function (dx, dy) {
xform = xform.translate(dx, dy);
return translate.call(ctx, dx, dy);
};

let pt = svg.createSVGPoint();
ctx.transformedPoint = (x, y) => {
pt.x = x;
pt.y = y;
return pt.matrixTransform(xform.inverse());
};
}

https://codepen.io/MKTechStation/pen/pobbKOj

I hope it can help someone. I am still open to any other better solution than that!!

solution

Zoom in/out at mouse position in canvas

In my mind, the way zoom works is that based on the position of the point of interest (in your case mouse), the zoom has a weighted impact on the (x, y) position of the canvas. If the mouse is at the top left, this weight should be (0,0), if it's at the bottom right, it should be (1,1). And the centre should be (0.5,0.5).

So, assuming we have the weights, whenever the zoom changes, the weights show us how far the top left corner of the canvas is from the point of interest, and we should move it away by the weight*dimension*(delta zoom).

Note that in order to calculate the weights, we need to consider the zoomed in/out value of the dimensions. So if width is 100 and zoom is 0.5, we should assume the width is 50. So all the values would be absolute (since the mouse (x,y) are absolute).

so, it's like:

function worldZoom(e) {
const {x, y, deltaY} = e;
const direction = deltaY > 0 ? -1 : 1;
const factor = 0.01;
const zoom = 1 * direction * factor;

// compute the weights for x and y
const wx = (x-controls.view.x)/(width*controls.view.zoom);
const wy = (y-controls.view.y)/(height*controls.view.zoom);

// apply the change in x,y and zoom.
controls.view.x -= wx*width*zoom;
controls.view.y -= wy*height*zoom;
controls.view.zoom += zoom;
}

You can try it in this codepen.

Or a bit simpler, compute the weights inline:

controls.view.x -= (x-controls.view.x)/(controls.view.zoom)*zoom;
controls.view.y -= (y-controls.view.y)/(controls.view.zoom)*zoom;
controls.view.zoom += zoom;

HTML5 canvas zoom where mouse coordinates

Annotated code and a Demo: http://jsfiddle.net/m1erickson/asT8x/

// clear the canvas

ctx.clearRect(0,0,canvas.width,canvas.height);

// save the context state

ctx.save();

// translate to the coordinate point you wish to zoom in on

ctx.translate(mouseX,mouseY);

// scale the canvas to the desired zoom level

ctx.scale(scale,scale);

// draw the image with an offset of -mouseX,-mouseY
// (offset to center image at the selected zoom point);

ctx.drawImage(img,-mouseX,-mouseY);

// restore the context to its untranslated/unrotated state

ctx.restore();

Canvas zoom to cursor doesn't work correct

In function scale() keep the same values for x and y before and after scaling. You just need to update coordinates in screen panX and panY.

var canvas;
var ctx;
var ww;
var wh;
var scaleFactor;
var panX;
var panY;

function draw() {
ctx.clearRect(0, 0, ww, wh);
let size = Math.min((ww - 10) / 60, (wh - 10) / 60);
let padding = {
x: ww - size * 60,
y: wh - size * 60,
}
ctx.save();
ctx.translate(panX, panY);
ctx.scale(scaleFactor, scaleFactor);
for (let i = 0; i < 60; i++) {
for (let j = 0; j < 60; j++) {
ctx.fillStyle = '#f' + i + j + 'f';
ctx.fillRect(padding.x / 2 + size * j, padding.y / 2 + size * i, size, size);
}
}
ctx.restore();
}

function scale(svg, e) {
let x = e.originalEvent.offsetX;
let y = e.originalEvent.offsetY;
let deltaY = e.originalEvent.deltaY;
let scale_now = deltaY < 0 ? 1.5 : 1 / 1.5;
scaleFactor *= scale_now;
panX = x - (x - panX) * scale_now;
panY = y - (y - panY) * scale_now;
draw();
}

function move(svg, x, y) {
panX += x;
panY += y;
draw();
}

function initialise() {
ctx = canvas.get(0).getContext('2d');
ww = canvas.outerWidth();
wh = canvas.outerHeight();
canvas.attr('width', ww);
canvas.attr('height', wh);
scaleFactor = 1.0;
panX = 0;
panY = 0;
draw();
}
$(document).ready(function() {
canvas = $('.canva');
initialise();
canvas.bind('mousewheel DOMMouseScroll', function(e) {
e.preventDefault();
});
canvas.on('mousewheel DOMMouseScroll', function(e) {
if (e.ctrlKey) {
scale($(this), e);
} else if (e.shiftKey) {
move($(this), -e.originalEvent.deltaY / 5, 0);
} else {
move($(this), 0, -e.originalEvent.deltaY / 5);
}
});
canvas.mousedown(function(e) {
if (e.which !== 2) return;
e.preventDefault();
$(this).css('cursor', 'move');
let old_x = e.offsetX;
let old_y = e.offsetY;
$(this).mousemove(function(emove) {
let x = emove.offsetX;
let y = emove.offsetY;
move($(this), emove.offsetX - old_x, emove.offsetY - old_y);
old_x = x;
old_y = y;
});
$(this).mouseup(function() {
$(this).off('mousemove');
$(this).css('cursor', 'default');
});
$(this).mouseleave(function() {
$(this).off('mousemove');
$(this).css('cursor', 'default');
});
});
});
canvas {
image-rendering: optimizeSpeed;
/* Older versions of FF */
image-rendering: -moz-crisp-edges;
/* FF 6.0+ */
image-rendering: -webkit-optimize-contrast;
/* Safari */
image-rendering: -o-crisp-edges;
/* OS X & Windows Opera (12.02+) */
image-rendering: pixelated;
/* Awesome future-browsers */
-ms-interpolation-mode: nearest-neighbor;
shape-rendering: crispEdges;
border: 1px solid black;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<canvas id="canvas" class="canva" width="200" height="200"></canvas>

Zoom/scale at mouse position

Use the canvas for zoomable content

Zooming and panning elements is very problematic. It can be done but the list of issues is very long. I would never implement such an interface.

Consider using the canvas, via 2D or WebGL to display such content to save your self many many problems.

The first part of the answer is implemented using the canvas. The same interface view is used in the second example that pans and zooms an element.

A simple 2D view.

As you are only panning and zooming then a very simple method can be used.

The example below implements an object called view. This holds the current scale and position (pan)

It provides two function for user interaction.

  • Panning the function view.pan(amount) will pan the view by distance in pixels held by amount.x, amount.y
  • Zooming the function view.scaleAt(at, amount) will scale (zoom in out) the view by amount (a number representing change in scale), at the position held by at.x, at.y in pixels.

In the example the view is applied to the canvas rendering context using view.apply() and a set of random boxes are rendered whenever the view changes.
The panning and zooming is via mouse events

Example using canvas 2D context

Use mouse button drag to pan, wheel to zoom

const ctx = canvas.getContext("2d");
canvas.width = 500;
canvas.height = 500;
const rand = (m = 255, M = m + (m = 0)) => (Math.random() * (M - m) + m) | 0;


const objects = [];
for (let i = 0; i < 100; i++) {
objects.push({x: rand(canvas.width), y: rand(canvas.height),w: rand(40),h: rand(40), col: `rgb(${rand()},${rand()},${rand()})`});
}

requestAnimationFrame(drawCanvas);

const view = (() => {
const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
var m = matrix; // alias
var scale = 1; // current scale
var ctx; // reference to the 2D context
const pos = { x: 0, y: 0 }; // current position of origin
var dirty = true;
const API = {
set context(_ctx) { ctx = _ctx; dirty = true },
apply() {
if (dirty) { this.update() }
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
},
get scale() { return scale },
get position() { return pos },
isDirty() { return dirty },
update() {
dirty = false;
m[3] = m[0] = scale;
m[2] = m[1] = 0;
m[4] = pos.x;
m[5] = pos.y;
},
pan(amount) {
if (dirty) { this.update() }
pos.x += amount.x;
pos.y += amount.y;
dirty = true;
},
scaleAt(at, amount) { // at in screen coords
if (dirty) { this.update() }
scale *= amount;
pos.x = at.x - (at.x - pos.x) * amount;
pos.y = at.y - (at.y - pos.y) * amount;
dirty = true;
},
};
return API;
})();
view.context = ctx;
function drawCanvas() {
if (view.isDirty()) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);

view.apply(); // set the 2D context transform to the view
for (i = 0; i < objects.length; i++) {
var obj = objects[i];
ctx.fillStyle = obj.col;
ctx.fillRect(obj.x, obj.y, obj.h, obj.h);
}
}
requestAnimationFrame(drawCanvas);
}


canvas.addEventListener("mousemove", mouseEvent, {passive: true});
canvas.addEventListener("mousedown", mouseEvent, {passive: true});
canvas.addEventListener("mouseup", mouseEvent, {passive: true});
canvas.addEventListener("mouseout", mouseEvent, {passive: true});
canvas.addEventListener("wheel", mouseWheelEvent, {passive: false});
const mouse = {x: 0, y: 0, oldX: 0, oldY: 0, button: false};
function mouseEvent(event) {
if (event.type === "mousedown") { mouse.button = true }
if (event.type === "mouseup" || event.type === "mouseout") { mouse.button = false }
mouse.oldX = mouse.x;
mouse.oldY = mouse.y;
mouse.x = event.offsetX;
mouse.y = event.offsetY
if(mouse.button) { // pan
view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
}
}
function mouseWheelEvent(event) {
var x = event.offsetX;
var y = event.offsetY;
if (event.deltaY < 0) { view.scaleAt({x, y}, 1.1) }
else { view.scaleAt({x, y}, 1 / 1.1) }
event.preventDefault();
}
body {
background: gainsboro;
margin: 0;
}
canvas {
background: white;
box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

Scale canvas to mouse position

A very basic approach to zoom a Canvas (or any other UIElement) at a specific position would be to use a MatrixTransform for the RenderTransform property

<Canvas Width="500" Height="500" MouseWheel="Canvas_MouseWheel">
<Canvas.RenderTransform>
<MatrixTransform/>
</Canvas.RenderTransform>
</Canvas>

and update the Matrix property of the transform like in this MouseWheel handler:

private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e)
{
var element = (UIElement)sender;
var position = e.GetPosition(element);
var transform = (MatrixTransform)element.RenderTransform;
var matrix = transform.Matrix;
var scale = e.Delta >= 0 ? 1.1 : (1.0 / 1.1); // choose appropriate scaling factor

matrix.ScaleAtPrepend(scale, scale, position.X, position.Y);
transform.Matrix = matrix;
}


Related Topics



Leave a reply



Submit