← Back to Writings

The Math Behind Cursor-Centered Zoom

Why my first zoom implementation was wrong, and the formula that finally made cursor-centered zoom work correctly at any zoom level.

October 7, 2025 Updated May 28, 2026
SwiftmacOSMathUIVLCKitCoordinate SystemsZoomAppKitCCTV App

Cursor-centered zoom sounds simple: zoom in toward wherever the cursor is. Pan adjusts so the point under the cursor stays fixed. I got it working at 1x fairly quickly. Above 1x it drifted. I spent longer than I’d like to admit figuring out why.

What I Had First

My initial implementation computed the cursor position relative to the view, then adjusted the pan offset proportionally to the zoom delta. Something like:

let delta = newZoom / oldZoom
pan.x = pan.x * delta + cursorOffset.x * (1 - delta)
pan.y = pan.y * delta + cursorOffset.y * (1 - delta)

This worked at low zoom levels. As zoom increased, the point under the cursor would drift - zoom in a few times and the image would slide away from where I was pointing. The error accumulated with each scroll event.

Finding the Root Cause

The bug was a coordinate space mismatch. I was computing cursorOffset in viewport space (the NSView bounds), but VLC’s pan values operate in VLC view space, which is normalized differently. At 1x zoom they’re close enough that the error is invisible. At higher zoom levels the spaces diverge and the drift becomes obvious.

The fix was to express everything in VLC view space consistently.

The Correct Formula

pan_new = pan_old + (cursorPos - viewSize / 2) * (1 - zoomRatio)

Where:

  • cursorPos is the cursor position in VLC view space
  • viewSize is the VLC view dimensions (not the NSView bounds)
  • zoomRatio is newZoom / oldZoom

In Swift:

let zoomRatio = newZoom / oldZoom
let vlcCursorX = cursorInView.x * (vlcViewSize.width / viewBounds.width)
let vlcCursorY = cursorInView.y * (vlcViewSize.height / viewBounds.height)

let newPanX = currentPan.x + (vlcCursorX - vlcViewSize.width / 2) * (1 - zoomRatio)
let newPanY = currentPan.y + (vlcCursorY - vlcViewSize.height / 2) * (1 - zoomRatio)

Why This Works

Think about what cursor-centered zoom means geometrically: the point under the cursor should have the same position in the view before and after the zoom. That gives us a constraint we can solve for.

Before zoom, the cursor is at position C in view space. The pan offset is P_old. After zoom by ratio r, the same world point maps to a different screen position unless we compensate with pan.

The world point under the cursor (in normalized coordinates) is:

world = (C - viewCenter) / oldZoom + P_old

After zoom, to keep that world point under the cursor:

C = (world - P_new) * newZoom + viewCenter

Solving for P_new:

P_new = world - (C - viewCenter) / newZoom
      = P_old + (C - viewCenter) * (1/oldZoom - 1/newZoom)

Which simplifies to the formula above when expressed as a zoom ratio. The key insight is that (C - viewCenter) - the cursor’s offset from the view center - is what drives the pan correction. At the center, the correction is zero. Further from center, the correction grows proportionally.

Symmetry

The formula works identically for zoom-in and zoom-out. When zooming out, zoomRatio < 1, so (1 - zoomRatio) > 0 and the pan moves toward the cursor. When zooming in, zoomRatio > 1, so (1 - zoomRatio) < 0 and the pan moves away. The math handles both cases without any branching.

This also means the same code path works for the fullscreen view and the grid cell view - the only difference is the vlcViewSize value passed in.

The Lesson

Coordinate space bugs are subtle because they often work at boundary conditions (zoom = 1, cursor at center) and only reveal themselves under real usage. When something drifts or accumulates error, the first question should be: am I mixing coordinate spaces? In this case, yes.

Once I drew out the geometry and derived the formula from first principles rather than intuition, the fix was obvious. The intuitive version was almost right - just applied in the wrong space.

Need a careful pair of eyes on your code?

I work on Swift, AppKit, and media-heavy macOS apps. Happy to review your codebase or take on a focused engineering engagement.

Get in touch