feat: pause/resume zoom animation at precision mode threshold

Instead of hard-capping the zoom at the precision mode threshold, now the
zoom animation pauses smoothly at the threshold and resumes when pointer
lock is activated.

**Implementation:**
- Changed useSpring to useSpring() to get API access (magnifierSpringApi)
- When approaching threshold without pointer lock:
  - Set target zoom to threshold level (animation continues smoothly)
  - Once at threshold, call magnifierSpringApi.pause()
- When pointer lock activates:
  - Call magnifierSpringApi.resume() to continue zoom animation
  - Animation resumes from paused state with same easing

**User experience:**
- Zoom smoothly animates towards threshold
- Pauses at threshold with visual indicators (grid, scrim, dimming)
- User clicks to activate precision mode
- Zoom animation seamlessly resumes from pause
- Continues zooming in with same smooth easing

This creates a continuous, flowing experience rather than a hard stop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-23 20:47:03 -06:00
parent 33d9f15897
commit 4687820d8a
1 changed files with 52 additions and 38 deletions

View File

@ -241,6 +241,10 @@ export function MapRenderer({
'[Pointer Lock] 📍 Saved initial capture position:',
initialCapturePositionRef.current
)
// Resume zoom animation if it was paused at precision mode threshold
magnifierSpringApi.resume()
console.log('[Pointer Lock] ▶️ Resumed zoom animation (precision mode activated)')
}
// Reset cursor squish when lock state changes
@ -278,20 +282,6 @@ export function MapRenderer({
}
}, [])
// When pointer lock state changes, update the spring target to current zoom
// This prevents jumps when transitioning between capped and uncapped zoom
useEffect(() => {
if (pointerLocked) {
// Just activated precision mode - set spring target to current value to avoid jump
const currentZoom = magnifierSpring.zoom.get()
setTargetZoom(currentZoom)
console.log(
'[Precision Mode] Activated - setting spring target to current zoom:',
currentZoom
)
}
}, [pointerLocked, magnifierSpring.zoom])
// Pre-compute largest piece sizes for multi-piece regions
useEffect(() => {
if (!svgRef.current) return
@ -355,7 +345,7 @@ export function MapRenderer({
// Zoom: smooth, slower animation with gentle easing
// Position: medium speed (300ms)
// Movement multiplier: gradual transitions for smooth cursor dampening
const magnifierSpring = useSpring({
const [magnifierSpringProps, magnifierSpringApi] = useSpring(() => ({
zoom: targetZoom,
opacity: targetOpacity,
top: targetTop,
@ -381,7 +371,8 @@ export function MapRenderer({
return { tension: 200, friction: 25 }
},
// onChange removed - was flooding console with animation frames
})
}))
const magnifierSpring = magnifierSpringProps
const [labelPositions, setLabelPositions] = useState<RegionLabelPosition[]>([])
const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState<
@ -1382,8 +1373,48 @@ export function MapRenderer({
)
}
// Always set the uncapped target zoom so spring animates quickly
// The capping will happen when rendering (in viewBox calculation)
// Handle precision mode threshold - pause zoom animation if needed
if (!pointerLocked && containerRef.current && svgRef.current) {
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (viewBoxWidth && !isNaN(viewBoxWidth)) {
// Calculate what the screen pixel ratio would be at this zoom
const magnifiedViewBoxWidth = viewBoxWidth / adaptiveZoom
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
const screenPixelRatio = mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
// If target zoom exceeds threshold, pause at threshold instead of capping
if (screenPixelRatio > PRECISION_MODE_THRESHOLD) {
// Calculate the exact zoom level that hits the threshold
const thresholdZoom = PRECISION_MODE_THRESHOLD / (magnifierWidth / svgRect.width)
// Get current animated zoom value
const currentZoom = magnifierSpring.zoom.get()
// If we're approaching or at threshold, pause the animation at threshold
if (currentZoom < thresholdZoom) {
// Still animating towards threshold - set target to threshold and let it continue
setTargetZoom(thresholdZoom)
console.log(
`[Magnifier] Approaching threshold - target set to ${thresholdZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px)`
)
} else {
// At or past threshold - pause the animation
magnifierSpringApi.pause()
console.log(
`[Magnifier] Paused at threshold ${currentZoom.toFixed(1)}× (waiting for precision mode)`
)
}
return // Don't set target zoom below, we've handled it
}
}
}
setTargetZoom(adaptiveZoom)
setShowMagnifier(true)
setTargetOpacity(1)
@ -1866,26 +1897,9 @@ export function MapRenderer({
const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX
const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY
// Clamp zoom at threshold when not in precision mode
// This creates a "pause" - spring keeps animating but rendered zoom stops at threshold
let effectiveZoom = zoom
if (!pointerLocked) {
const magnifierWidth = containerRect.width * 0.5
const magnifiedViewBoxWidth = viewBoxWidth / zoom
const magnifierScreenPixelsPerSvgUnit = magnifierWidth / magnifiedViewBoxWidth
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
const screenPixelRatio =
mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
if (screenPixelRatio > PRECISION_MODE_THRESHOLD) {
const maxZoom = PRECISION_MODE_THRESHOLD / (magnifierWidth / svgRect.width)
effectiveZoom = Math.min(zoom, maxZoom)
}
}
// Magnified view: use effective (clamped) zoom
const magnifiedWidth = viewBoxWidth / effectiveZoom
const magnifiedHeight = viewBoxHeight / effectiveZoom
// Magnified view: adaptive zoom (using animated value)
const magnifiedWidth = viewBoxWidth / zoom
const magnifiedHeight = viewBoxHeight / zoom
// Center the magnified viewBox on the cursor
const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2