feat: pause/resume zoom animation at precision mode threshold

Implement smooth pause/resume behavior for zoom animation when hitting
the precision mode threshold, instead of hard-capping the zoom.

**Changes:**
- Added `useSpringRef` API to control spring animation
- Track desired (uncapped) zoom in ref for later resume
- Pause zoom animation when threshold is reached (not in pointer lock)
- Resume animation when pointer lock is activated
- Animation smoothly continues from paused position to desired zoom

**Behavior:**
1. User zooms in, animation smoothly increases zoom
2. At 20 px/px threshold (without pointer lock): animation pauses
3. Visual indicators appear: dimmed magnifier, gold scrim, pixel grid
4. User clicks to activate precision mode (pointer lock)
5. Animation resumes from current zoom to desired zoom
6. Smooth continuation rather than jarring jump or hard stop

**Technical implementation:**
- `desiredZoomRef`: Stores uncapped zoom user wants to reach
- `springApi.pause()`: Pauses animation at threshold
- `springApi.resume()`: Resumes when pointer lock activated
- `setTargetZoom(desiredZoomRef.current)`: Updates target for resume

This creates a "pause at barrier, resume when cleared" UX rather than
a hard wall that prevents further zooming.

🤖 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:50:35 -06:00
parent 4687820d8a
commit bdf59e571d
1 changed files with 32 additions and 28 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { useState, useMemo, useRef, useEffect } from 'react'
import { useSpring, animated } from '@react-spring/web'
import { useSpring, useSpringRef, animated } from '@react-spring/web'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import type { MapData, MapRegion } from '../types'
@ -179,6 +179,7 @@ export function MapRenderer({
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
const [showMagnifier, setShowMagnifier] = useState(false)
const [targetZoom, setTargetZoom] = useState(10)
const desiredZoomRef = useRef(10) // Uncapped zoom that user wants to reach
const [targetOpacity, setTargetOpacity] = useState(0)
const [targetTop, setTargetTop] = useState(20)
const [targetLeft, setTargetLeft] = useState(20)
@ -242,9 +243,13 @@ export function MapRenderer({
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)')
// Resume zoom animation to reach desired zoom level
// (may have been paused at precision mode threshold)
setTargetZoom(desiredZoomRef.current)
springApi.current?.resume()
console.log(
`[Magnifier] Resuming zoom animation (pointer lock activated, target: ${desiredZoomRef.current.toFixed(1)}×)`
)
}
// Reset cursor squish when lock state changes
@ -345,7 +350,9 @@ export function MapRenderer({
// Zoom: smooth, slower animation with gentle easing
// Position: medium speed (300ms)
// Movement multiplier: gradual transitions for smooth cursor dampening
const [magnifierSpringProps, magnifierSpringApi] = useSpring(() => ({
const springApi = useSpringRef()
const magnifierSpring = useSpring({
ref: springApi,
zoom: targetZoom,
opacity: targetOpacity,
top: targetTop,
@ -371,8 +378,7 @@ 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<
@ -1373,7 +1379,11 @@ export function MapRenderer({
)
}
// Handle precision mode threshold - pause zoom animation if needed
// Store the desired zoom (uncapped) for later resume
desiredZoomRef.current = adaptiveZoom
// Cap zoom if not in pointer lock mode to prevent excessive screen pixel ratios
// But pause the animation at the threshold rather than hard-capping
if (!pointerLocked && containerRef.current && svgRef.current) {
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
@ -1388,29 +1398,23 @@ export function MapRenderer({
const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width
const screenPixelRatio = mainMapSvgUnitsPerScreenPixel * magnifierScreenPixelsPerSvgUnit
// If target zoom exceeds threshold, pause at threshold instead of capping
// If it exceeds threshold, cap the zoom and pause animation
if (screenPixelRatio > PRECISION_MODE_THRESHOLD) {
// Calculate the exact zoom level that hits the threshold
const thresholdZoom = PRECISION_MODE_THRESHOLD / (magnifierWidth / svgRect.width)
// Solve for max zoom: ratio = zoom * (magnifierWidth / mainMapWidth)
const maxZoom = PRECISION_MODE_THRESHOLD / (magnifierWidth / svgRect.width)
const cappedZoom = Math.min(adaptiveZoom, maxZoom)
// Get current animated zoom value
const currentZoom = magnifierSpring.zoom.get()
// Set target to capped value
setTargetZoom(cappedZoom)
// 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
// Pause the zoom animation at this threshold
// We'll resume it when pointer lock is activated
springApi.current?.pause()
console.log(
`[Magnifier] Pausing zoom at threshold ${cappedZoom.toFixed(1)}× (desired: ${adaptiveZoom.toFixed(1)}×, threshold: ${PRECISION_MODE_THRESHOLD} px/px)`
)
return // Early return to skip the normal setTargetZoom below
}
}
}