feat(know-your-world): Phase 2 - integrate useMagnifierZoom hook

Integrate the useMagnifierZoom hook into MapRenderer, completing Phase 2 of the refactoring plan. This extracts ~152 lines of zoom animation logic from the component into a reusable hook.

**Changes:**

Hook Updates:
- Updated `useMagnifierZoom` to return spring object instead of `.get()` value
- This allows MapRenderer to use `.to()` interpolation for animated values
- Return type now `any` (spring object) instead of `number`

MapRenderer Reductions:
- Replaced ~101 line zoom effect with 7-line effect
- Effect now only updates opacity/position/movementMultiplier
- Zoom animation with pause/resume handled by hook
- Removed all `magnifierSpring.zoom` references (11 occurrences)
- Replaced with `zoomSpring` from hook or `getCurrentZoom()` calls
- Added TypeScript type annotations for all zoom parameters

**Line Count Impact:**
- Before Phase 2: 2083 lines
- After Phase 2: 1931 lines (-152 lines, -7.3%)
- Combined total: 2430 → 1931 lines (-499 lines, -20.5% reduction)

**Testing:**
- TypeScript: No new errors (only pre-existing test errors)
- Formatting: Passed
- Linting: No new MapRenderer warnings

**Files Modified:**
- `hooks/useMagnifierZoom.ts` - Return spring object instead of number
- `components/MapRenderer.tsx` - Integrate zoom hook, remove zoom effect

**Next Steps:**
- User testing of magnifier behavior
- Verify zoom animation, pause/resume, capping
- Verify pointer lock integration works correctly

🤖 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-24 07:31:52 -06:00
parent fb55a1ce53
commit 8ce878d03e
2 changed files with 43 additions and 195 deletions

View File

@ -22,6 +22,7 @@ import {
import { findOptimalZoom } from '../utils/adaptiveZoomSearch'
import { useRegionDetection } from '../hooks/useRegionDetection'
import { usePointerLock } from '../hooks/usePointerLock'
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
// Debug flag: show technical info in magnifier (dev only)
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
@ -193,15 +194,13 @@ export function MapRenderer({
smallRegionAreaThreshold: 200,
})
// State that needs to be available for pointer lock callbacks
const [targetZoom, setTargetZoom] = useState(10)
const uncappedAdaptiveZoomRef = useRef<number | null>(null)
// State that needs to be available for hooks
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const initialCapturePositionRef = useRef<{ x: number; y: number } | null>(null)
const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 })
const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false)
// Pointer lock hook
// Pointer lock hook (needed by zoom hook)
const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({
containerRef,
onLockAcquired: () => {
@ -213,76 +212,29 @@ export function MapRenderer({
initialCapturePositionRef.current
)
}
// Update target zoom to uncapped value
if (uncappedAdaptiveZoomRef.current !== null) {
console.log(
`[Pointer Lock] Updating target zoom to uncapped value: ${uncappedAdaptiveZoomRef.current.toFixed(1)}×`
)
setTargetZoom(uncappedAdaptiveZoomRef.current)
}
// Note: Zoom update now handled by useMagnifierZoom hook
},
onLockReleased: () => {
console.log('[Pointer Lock] 🔓 RELEASED - Starting cleanup and zoom recalculation')
console.log('[Pointer Lock] 🔓 RELEASED - Starting cleanup')
// Reset cursor squish
setCursorSquish({ x: 1, y: 1 })
setIsReleasingPointerLock(false)
// Recalculate zoom with capping
if (uncappedAdaptiveZoomRef.current !== null && 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 && !Number.isNaN(viewBoxWidth)) {
const uncappedZoom = uncappedAdaptiveZoomRef.current
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: uncappedZoom,
})
console.log('[Pointer Lock] Screen pixel ratio check:', {
uncappedZoom: uncappedZoom.toFixed(1),
screenPixelRatio: screenPixelRatio.toFixed(1),
threshold: PRECISION_MODE_THRESHOLD,
exceedsThreshold: isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD),
})
// If it exceeds threshold, cap the zoom to stay at threshold
if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) {
const maxZoom = calculateMaxZoomAtThreshold(
PRECISION_MODE_THRESHOLD,
magnifierWidth,
svgRect.width
)
const cappedZoom = Math.min(uncappedZoom, maxZoom)
console.log(
`[Pointer Lock] ✅ Capping zoom: ${uncappedZoom.toFixed(1)}×${cappedZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px)`
)
setTargetZoom(cappedZoom)
} else {
console.log(
`[Pointer Lock] No capping needed - zoom ${uncappedZoom.toFixed(1)}× is below threshold`
)
}
} else {
console.log('[Pointer Lock] ⚠️ Cannot recalculate zoom - invalid viewBoxWidth')
}
} else {
console.log('[Pointer Lock] ⚠️ Cannot recalculate zoom - missing refs:', {
hasContainer: !!containerRef.current,
hasSvg: !!svgRef.current,
hasUncappedZoom: uncappedAdaptiveZoomRef.current !== null,
})
}
// Note: Zoom recalculation now handled by useMagnifierZoom hook
},
})
// Magnifier zoom hook
const { targetZoom, setTargetZoom, zoomSpring, getCurrentZoom, uncappedAdaptiveZoomRef } =
useMagnifierZoom({
containerRef,
svgRef,
viewBox: mapData.viewBox,
threshold: PRECISION_MODE_THRESHOLD,
pointerLocked,
initialZoom: 10,
})
const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 })
const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null)
const [showMagnifier, setShowMagnifier] = useState(false)
@ -371,15 +323,10 @@ export function MapRenderer({
}
// Animated spring values for smooth transitions
// Different fade speeds: fast fade-in (100ms), slow fade-out (1000ms)
// Zoom: smooth, slower animation with gentle easing
// Position: medium speed (300ms)
// Movement multiplier: gradual transitions for smooth cursor dampening
const springRef = useSpringRef()
// Note: Zoom animation is now handled by useMagnifierZoom hook
// This spring only handles: opacity, position, and movement multiplier
const [magnifierSpring, magnifierApi] = useSpring(
() => ({
ref: springRef,
zoom: targetZoom,
opacity: targetOpacity,
top: targetTop,
left: targetLeft,
@ -390,126 +337,27 @@ export function MapRenderer({
? { duration: 100 } // Fade in: 0.1 seconds
: { duration: 1000 } // Fade out: 1 second
}
if (key === 'zoom') {
// Zoom: very slow, smooth animation (4x longer than before)
// Lower tension + higher mass = longer, more gradual transitions
return { tension: 30, friction: 30, mass: 4 }
}
if (key === 'movementMultiplier') {
// Movement multiplier: smooth but responsive transitions
// Faster than zoom so cursor responsiveness changes quickly but not jarring
return { tension: 180, friction: 26 }
}
// Position: medium speed
return { tension: 200, friction: 25 }
},
// onChange removed - was flooding console with animation frames
}),
[]
[targetOpacity, targetTop, targetLeft, smallestRegionSize]
)
// Update spring values when targets change
// Handle pausing zoom animation when hitting threshold
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
useEffect(() => {
const currentZoom = magnifierSpring.zoom.get()
const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01
console.log('[Zoom Effect] Running with state:', {
currentZoom: currentZoom.toFixed(1),
targetZoom: targetZoom.toFixed(1),
zoomIsAnimating,
pointerLocked,
})
// Check if CURRENT zoom is at/above the threshold (zoom is capped)
const currentIsAtThreshold =
!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 || Number.isNaN(viewBoxWidth)) return false
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: currentZoom,
})
return isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)
})()
// Check if TARGET zoom would be at/above the threshold
const targetIsAtThreshold =
!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 || Number.isNaN(viewBoxWidth)) return false
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: targetZoom,
})
return isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)
})()
console.log('[Zoom Effect] Threshold checks:', {
currentIsAtThreshold,
targetIsAtThreshold,
shouldPause: currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold,
})
// Pause conditions:
// 1. Currently at threshold AND animating toward even higher zoom (would exceed threshold more)
// 2. OR: Currently at threshold and target is also at threshold (should stay paused)
const shouldPause = currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold
if (shouldPause) {
// Pause the zoom animation - we're waiting for precision mode
console.log('[Zoom] ⏸️ Pausing at threshold - waiting for precision mode')
magnifierApi.pause()
} else {
// Update spring values and ensure it's not paused
// This will resume if we were paused and now target is below threshold (zooming out)
if (currentIsAtThreshold && !targetIsAtThreshold) {
console.log('[Zoom] ▶️ Resuming - target zoom is below threshold (zooming out)')
}
console.log('[Zoom] 🎬 Starting/updating animation to targetZoom:', targetZoom.toFixed(1))
magnifierApi.start({
zoom: targetZoom,
opacity: targetOpacity,
top: targetTop,
left: targetLeft,
movementMultiplier: getMovementMultiplier(smallestRegionSize),
})
}
}, [
targetZoom,
targetOpacity,
targetTop,
targetLeft,
smallestRegionSize,
pointerLocked,
mapData.viewBox,
magnifierApi,
magnifierSpring.zoom,
])
}, [targetOpacity, targetTop, targetLeft, smallestRegionSize, magnifierApi])
const [labelPositions, setLabelPositions] = useState<RegionLabelPosition[]>([])
const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState<
@ -1438,7 +1286,7 @@ export function MapRenderer({
{/* Magnifier region indicator on main map */}
{showMagnifier && cursorPosition && svgRef.current && containerRef.current && (
<animated.rect
x={magnifierSpring.zoom.to((zoom) => {
x={zoomSpring.to((zoom: number) => {
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
@ -1454,7 +1302,7 @@ export function MapRenderer({
const magnifiedWidth = viewBoxWidth / zoom
return cursorSvgX - magnifiedWidth / 2
})}
y={magnifierSpring.zoom.to((zoom) => {
y={zoomSpring.to((zoom: number) => {
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
@ -1470,12 +1318,12 @@ export function MapRenderer({
const magnifiedHeight = viewBoxHeight / zoom
return cursorSvgY - magnifiedHeight / 2
})}
width={magnifierSpring.zoom.to((zoom) => {
width={zoomSpring.to((zoom: number) => {
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2] || 1000
return viewBoxWidth / zoom
})}
height={magnifierSpring.zoom.to((zoom) => {
height={zoomSpring.to((zoom: number) => {
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxHeight = viewBoxParts[3] || 1000
return viewBoxHeight / zoom
@ -1694,8 +1542,8 @@ export function MapRenderer({
width: '50%',
aspectRatio: '2/1',
// High zoom (>60x) gets gold border, normal zoom gets blue border
border: magnifierSpring.zoom.to(
(zoom) =>
border: zoomSpring.to(
(zoom: number) =>
zoom > HIGH_ZOOM_THRESHOLD
? `4px solid ${isDark ? '#fbbf24' : '#f59e0b'}` // gold-400/gold-500
: `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}` // blue-400/blue-600
@ -1704,7 +1552,7 @@ export function MapRenderer({
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 100,
boxShadow: magnifierSpring.zoom.to((zoom) =>
boxShadow: zoomSpring.to((zoom: number) =>
zoom > HIGH_ZOOM_THRESHOLD
? '0 10px 40px rgba(251, 191, 36, 0.4), 0 0 20px rgba(251, 191, 36, 0.2)' // Gold glow
: '0 10px 40px rgba(0, 0, 0, 0.5)'
@ -1714,7 +1562,7 @@ export function MapRenderer({
}}
>
<animated.svg
viewBox={magnifierSpring.zoom.to((zoom) => {
viewBox={zoomSpring.to((zoom: number) => {
// Calculate magnified viewBox centered on cursor
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
@ -1761,7 +1609,7 @@ export function MapRenderer({
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return 'none'
const currentZoom = magnifierSpring.zoom.get()
const currentZoom = getCurrentZoom()
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
@ -1866,7 +1714,7 @@ export function MapRenderer({
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return null
const currentZoom = magnifierSpring.zoom.get()
const currentZoom = getCurrentZoom()
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
@ -1996,7 +1844,7 @@ export function MapRenderer({
}}
data-element="magnifier-label"
>
{magnifierSpring.zoom.to((z) => {
{zoomSpring.to((z: number) => {
const multiplier = magnifierSpring.movementMultiplier.get()
// When in pointer lock mode, show "Precision mode active" notice
@ -2052,7 +1900,7 @@ export function MapRenderer({
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return null
const currentZoom = magnifierSpring.zoom.get()
const currentZoom = getCurrentZoom()
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,

View File

@ -34,8 +34,8 @@ export interface UseMagnifierZoomReturn {
targetZoom: number
/** Set the target zoom level */
setTargetZoom: (zoom: number) => void
/** The animated spring value for zoom */
zoomSpring: number
/** The animated spring value for zoom (spring object, not a number) */
zoomSpring: any // Spring value that can be used with animated.div
/** Get the current animated zoom value */
getCurrentZoom: () => number
/** Reference to the uncapped adaptive zoom (for pointer lock transitions) */
@ -233,7 +233,7 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
return {
targetZoom,
setTargetZoom,
zoomSpring: magnifierSpring.zoom.get(),
zoomSpring: magnifierSpring.zoom, // Return the spring object, not .get()
getCurrentZoom: () => magnifierSpring.zoom.get(),
uncappedAdaptiveZoomRef,
}