There are probably multiple ways to solve this problem, but here's one:
Imaging your viewport is an NxM rectangle, and you are drawing some portion of a scene in within that viewport. In order to zoom in and out you can shift the origin at which you draw that scene and increase or decrease the scale. The tricky part is to make it possible to zoom in and out centered on an arbitrary point within the currently visible portion of the scene, keeping that point in the scene locked to the current point in the viewport.

Given some center point, and a desired scale factor, it is possible to determine the necessary change in the offset of the scene to preserve the position of the center point after scaling.

There's probably some complicated trigonometric proof for how to calculate this, but conveniently it is a simple calculation based on the ratio of the offset of the mouse from the current top left of the scene, to the scaled height of the scene.
x_offset -= (x_center - x_offset) / (N * current_scale) * (N * new_scale - N * current_scale)
y_offset -= (y_center - y_offset) / (M * current_scale) * (M * new_scale - M * current_scale)
Conveniently it is possible to apply this repeatedly as your scale changes and regardless of whether scale is increasing or decreasing.
Here's a sample sketch demonstrating this:
const viewport = { width: 400, height: 300 };
let scaledView = { ...viewport, x: 0, y: 0 };
function setup() {
createCanvas(windowWidth, windowHeight);
viewport.x = (width - viewport.width) / 2;
viewport.y = (height - viewport.height) / 2;
}
function draw() {
background(255);
translate(viewport.x, viewport.y);
push();
translate(scaledView.x, scaledView.y);
scale(scaledView.width / viewport.width, scaledView.height / viewport.height);
// Draw scene
ellipseMode(CENTER);
noStroke();
fill(200);
rect(0, 0, viewport.width, viewport.height);
stroke('blue');
noFill();
strokeWeight(1);
translate(viewport.width / 2, viewport.height / 2);
circle(0, 0, 200);
arc(0, 0, 120, 120, PI * 0.25, PI * 0.75);
strokeWeight(4)
point(-40, -40);
point(40, -40);
pop();
noFill();
stroke(0);
rect(0, 0, viewport.width, viewport.height);
// viewport relative mouse position
let mousePos = { x: mouseX - viewport.x, y: mouseY - viewport.y };
if (mousePos.x >= 0 && mousePos.x <= viewport.width &&
mousePos.y >= 0 && mousePos.y <= viewport.height) {
line(scaledView.x, scaledView.y, mousePos.x, mousePos.y);
let updatedView = keyIsDown(SHIFT) ? getUnZoomedView(mousePos) : getZoomedView(mousePos);
line(scaledView.x, scaledView.y, updatedView.x, updatedView.y);
stroke('red');
rect(updatedView.x, updatedView.y, updatedView.width, updatedView.height);
}
}
function getZoomedView(center) {
return getScaledView(center, 1.1);
}
function getUnZoomedView(center) {
return getScaledView(center, 0.9);
}
function getScaledView(center, factor) {
// the center position relative to the scaled/shifted scene
let viewCenterPos = {
x: center.x - scaledView.x,
y: center.y - scaledView.y
};
// determine how much we will have to shift to keep the position centered
let shift = {
x: map(viewCenterPos.x, 0, scaledView.width, 0, 1),
y: map(viewCenterPos.y, 0, scaledView.height, 0, 1)
};
// calculate the new view dimensions
let updatedView = {
width: scaledView.width * factor,
height: scaledView.height * factor
};
// adjust the x and y offsets according to the shift
updatedView.x = scaledView.x + (updatedView.width - scaledView.width) * -shift.x;
updatedView.y = scaledView.y + (updatedView.height - scaledView.height) * -shift.y;
return updatedView;
}
function mouseClicked() {
// viewport relative mouse position
let mousePos = { x: mouseX - viewport.x, y: mouseY - viewport.y };
if (mousePos.x >= 0 && mousePos.x <= viewport.width &&
mousePos.y >= 0 && mousePos.y <= viewport.height) {
scaledView = keyIsDown(SHIFT) ? getUnZoomedView(mousePos) : getZoomedView(mousePos);
}
}
html, body {
margin: 0;
padding: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>
We can actually simplify these equations because N and M can be factored out. The previous pseudo code becomes:
x_offset -= ((x_center - x_offset) * (new_scale - current_scale) * N) / (current_scale * N)
And because both the top and bottom are multiplied by N
this becomes:
x_offset -= ((x_center - x_offset) * (new_scale - current_scale)) / (current_scale)
Here is an example using your image drawing code:
const IMG_PATHS = [
'https://storage.googleapis.com/www.paulwheeler.us/files/windows-95-desktop-background.jpg'
];
let IMGS = [];
const minScale = 1;
const maxScale = 5;
let targetScale = minScale;
let currentScale = targetScale;
let targetOrigin = {
x: 0,
y: 0
};
let currentOrigin = {
x: 0,
y: 0
};
let idx = 0;
function preloadHelper(pathsToImgs, imgs) {
for (let pathToImg of pathsToImgs) {
loadImage(pathToImg, img => {
imgs.push(img);
})
}
}
// Make sure load images happen from the actual preload() function. p5.js has special logic when these calls happen here to have the sketch wait to start until all the loadXXX class are complete
function preload() {
preloadHelper(IMG_PATHS, IMGS);
}
function setup() {
pixelDensity(1)
createCanvas(windowWidth, windowHeight)
frameRate(12)
}
function draw() {
clear();
/*
if (currentScale < targetScale) {
currentScale += 0.01
} else if (currentScale > targetScale) {
currentScale -= 0.01
} */
// By making all of the changing components part of a vector and normalizing it we can ensure that the we reach our target origin and scale at the same point
let offset = createVector(
targetOrigin.x - currentOrigin.x,
targetOrigin.y - currentOrigin.y,
// Give the change in scale more weight so that it happens at a similar rate to the translation. This is especially noticable when there is little to no offset required
(targetScale - currentScale) * 500
);
if (offset.magSq() > 0.01) {
// Multiplying by a larger number will move faster
offset.normalize().mult(8);
currentOrigin.x += offset.x;
currentOrigin.y += offset.y;
currentScale += offset.z / 500;
// We need to make sure we do not over shoot or targets
if (offset.x > 0 && currentOrigin.x > targetOrigin.x) {
currentOrigin.x = targetOrigin.x;
}
if (offset.x < 0 && currentOrigin.x < targetOrigin.x) {
currentOrigin.x = targetOrigin.x;
}
if (offset.y > 0 && currentOrigin.y > targetOrigin.y) {
currentOrigin.y = targetOrigin.y;
}
if (offset.y < 0 && currentOrigin.y < targetOrigin.y) {
currentOrigin.y = targetOrigin.y;
}
if (offset.z > 0 && currentScale > targetScale) {
currentScale = targetScale;
}
if (offset.z < 0 && currentScale < targetScale) {
currentScale = targetScale;
}
}
translate(currentOrigin.x, currentOrigin.y);
scale(currentScale);
image(IMGS[idx], 0, 0);
}
function mouseClicked() {
targetScale = constrain(
keyIsDown(SHIFT) ? currentScale * 0.9 : currentScale * 1.1,
minScale,
maxScale
);
targetOrigin = getScaledOrigin({
x: mouseX,
y: mouseY
},
currentScale,
targetScale
);
}
function getScaledOrigin(center, currentScale, newScale) {
// the center position relative to the scaled/shifted scene
let viewCenterPos = {
x: center.x - currentOrigin.x,
y: center.y - currentOrigin.y
};
// determine the new origin
let originShift = {
x: viewCenterPos.x / currentScale * (newScale - currentScale),
y: viewCenterPos.y / currentScale * (newScale - currentScale)
};
return {
x: currentOrigin.x - originShift.x,
y: currentOrigin.y - originShift.y
};
}
html,
body {
margin: 0;
padding: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>