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 byamount.x
,amount.y
- Zooming the function
view.scaleAt(at, amount)
will scale (zoom in out) the view byamount
(a number representing change in scale), at the position held byat.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>
Example using element.style.transform
This example uses the element style transform property to zoom and pan.
-
Note that I use a 2D matrix rather than the 3d matrix as that can introduce many problems not compatible with the simple zoom and pan used below.
-
Note that CSS transforms are not applied to the top left of the element in all cases. In the example below the origin is in the center of the element. Thus when zooming the zoom at point must be adjusted by subtracting half the elements size. The element size is not effected by the transform.
-
Note borders, padding, and margins will also change the location of the origin. To work with
view.scaleAt(at, amount)
at
must be relative to the top left most pixel of the element -
Note there are many more problems and caveats you need to consider when you zoom and pan elements, too many to fit in a single answer. That is why this answer starts with a canvas example as it is by far the safer method to managing zoom-able visual content.
Use mouse button drag to pan, wheel to zoom. If you lose your position (zoom too far in out or panned of the page restart the snippet)
const view = (() => {
const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
var m = matrix; // alias
var scale = 1; // current scale
const pos = { x: 0, y: 0 }; // current position of origin
var dirty = true;
const API = {
applyTo(el) {
if (dirty) { this.update() }
el.style.transform = `matrix(${m[0]},${m[1]},${m[2]},${m[3]},${m[4]},${m[5]})`;
},
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;
})();
document.addEventListener("mousemove", mouseEvent, {passive: false});
document.addEventListener("mousedown", mouseEvent, {passive: false});
document.addEventListener("mouseup", mouseEvent, {passive: false});
document.addEventListener("mouseout", mouseEvent, {passive: false});
document.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.pageX;
mouse.y = event.pageY;
if(mouse.button) { // pan
view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
view.applyTo(zoomMe);
}
event.preventDefault();
}
function mouseWheelEvent(event) {
const x = event.pageX - (zoomMe.width / 2);
const y = event.pageY - (zoomMe.height / 2);
if (event.deltaY < 0) {
view.scaleAt({x, y}, 1.1);
view.applyTo(zoomMe);
} else {
view.scaleAt({x, y}, 1 / 1.1);
view.applyTo(zoomMe);
}
event.preventDefault();
}
body {
user-select: none;
-moz-user-select: none;
}
.zoomables {
pointer-events: none;
border: 1px solid black;
}
#zoomMe {
position: absolute;
top: 0px;
left: 0px;
}
<img id="zoomMe" class="zoomables" src="https://i.stack.imgur.com/C7qq2.png?s=328&g=1">