2

In the following p5.js code I'm trying to create 2 separate methods.

centerInWindow() is meant to keep the image centered in the canvas while it's being scaled down after the user clicks on the canvas.

centerToClick() is meant to keep the image centered on the point the user clicked on, while it's being scaled up.

None of them work and I'm having trouble getting the logic right.

function centerInWindow(img) {
  let currentSize = img.width * currentScale
  imgX = (windowWidth / 2) - (currentSize / 2) 
  imgY = (windowHeight / 2) - (currentSize / 2)
}

function centerToClick() {
  imgX = clickX * currentScale
  imgY = clickY * currentScale
}

let minScale = 1
let maxScale = 5
let targetScale = minScale
let currentScale = targetScale

let clickX, clickY, imgX, imgY

let idx = 0

function setup() {
  pixelDensity(1)
  createCanvas(windowWidth, windowHeight)
  preload(IMG_PATHS, IMGS)
  frameRate(12)  
}

function draw() { 
  clear()
  
  if (currentScale < targetScale) {
    currentScale += 0.05
    if (currentScale > targetScale) {
      currentScale = targetScale
    }
    centerToClick()
  } else if (currentScale > targetScale) {
    currentScale -= 0.05
    if (currentScale < targetScale) {
      currentScale = targetScale
    }
    centerInWindow(IMGS[idx])
  } else {
    centerInWindow(IMGS[idx])
  }
    
  scale(currentScale)
    
  image(IMGS[idx], imgX, imgY)
      
  idx++
  
  if (idx === IMGS.length) {
    idx = 0
  }  
}

window.addEventListener('click', function({ clientX, clientY }) {
  targetScale = targetScale === maxScale ? minScale : maxScale
  
  clickX = clientX
  clickY = clientY
})

See it in action here.

Any help would be appreciated.

Kawd
  • 4,122
  • 10
  • 37
  • 68

1 Answers1

4

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.

An example viewport and scene

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.

The viewport being zoomed

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>
Paul Wheeler
  • 18,988
  • 3
  • 28
  • 41
  • A very similar approach to this problem can be found in this answer to a similar question: https://stackoverflow.com/a/70660569/229247 – Paul Wheeler Jan 28 '22 at 03:37
  • Wow thank you so much for this. I will take a look at it more thoroughly later today to understand what's happening (!) – Kawd Jan 28 '22 at 11:25