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.
Series: Building a CCTV App for macOS
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:
cursorPosis the cursor position in VLC view spaceviewSizeis the VLC view dimensions (not the NSView bounds)zoomRatioisnewZoom / 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.
Series: Building a CCTV App for macOS
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