refactor: extract useMagnifierZoom hook (Phase 2)

Create custom hook to encapsulate zoom state management and animation:
- Zoom state: targetZoom, uncappedAdaptiveZoomRef
- React Spring animation with smooth easing config
- Automatic pause/resume at precision mode threshold
- Zoom capping when pointer lock state changes
- Recalculation of capped zoom when pointer lock is released

This hook replaces ~150 lines of zoom-related logic in MapRenderer.tsx.

Part of the ongoing refactoring to reduce MapRenderer.tsx from 2409 lines
to ~300-500 lines by extracting utilities and custom hooks.

🤖 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 06:01:50 -06:00
parent d44bbe2769
commit e4591b6b39

View File

@@ -0,0 +1,240 @@
/**
* Magnifier Zoom Hook
*
* Manages zoom state, animation, and threshold-based capping for the magnifier.
* Handles smooth zoom transitions, pausing at precision mode threshold, and
* coordinating with pointer lock state.
*/
import { useState, useEffect, useRef, type RefObject } from 'react'
import { useSpring, useSpringRef } from '@react-spring/web'
import {
calculateScreenPixelRatio,
calculateMaxZoomAtThreshold,
isAboveThreshold,
} from '../utils/screenPixelRatio'
export interface UseMagnifierZoomOptions {
/** The container element (for calculating dimensions) */
containerRef: RefObject<HTMLDivElement>
/** The SVG element (for calculating dimensions) */
svgRef: RefObject<SVGSVGElement>
/** The SVG viewBox string (e.g., "0 0 1000 500") */
viewBox: string
/** Precision mode threshold in px/px (e.g., 20) */
threshold: number
/** Whether pointer lock is currently active */
pointerLocked: boolean
/** Initial zoom level */
initialZoom?: number
}
export interface UseMagnifierZoomReturn {
/** Current target zoom level (may be capped) */
targetZoom: number
/** Set the target zoom level */
setTargetZoom: (zoom: number) => void
/** The animated spring value for zoom */
zoomSpring: number
/** Get the current animated zoom value */
getCurrentZoom: () => number
/** Reference to the uncapped adaptive zoom (for pointer lock transitions) */
uncappedAdaptiveZoomRef: React.MutableRefObject<number | null>
}
/**
* Custom hook for managing magnifier zoom state and animation.
*
* This hook encapsulates:
* - Zoom state management (target zoom, uncapped zoom ref)
* - React Spring animation with configurable easing
* - Automatic pause/resume at precision mode threshold
* - Zoom capping when not in pointer lock mode
* - Recalculation when pointer lock state changes
*
* @param options - Configuration options
* @returns Zoom state and control methods
*/
export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifierZoomReturn {
const { containerRef, svgRef, viewBox, threshold, pointerLocked, initialZoom = 10 } = options
const [targetZoom, setTargetZoom] = useState(initialZoom)
const uncappedAdaptiveZoomRef = useRef<number | null>(null)
// Set up React Spring animation for smooth zoom transitions
const springRef = useSpringRef()
const [magnifierSpring, magnifierApi] = useSpring(
() => ({
ref: springRef,
zoom: targetZoom,
config: {
// Very slow, smooth animation for zoom
// Lower tension + higher mass = longer, more gradual transitions
tension: 30,
friction: 30,
mass: 4,
},
}),
[]
)
// Handle pointer lock state changes - recalculate zoom with capping
useEffect(() => {
// When pointer lock is released, cap zoom if it exceeds threshold
if (!pointerLocked && uncappedAdaptiveZoomRef.current !== null) {
const containerElement = containerRef.current
const svgElement = svgRef.current
if (!containerElement || !svgElement) {
console.log('[useMagnifierZoom] Cannot recalculate zoom - missing refs')
return
}
const containerRect = containerElement.getBoundingClientRect()
const svgRect = svgElement.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
const viewBoxParts = viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) {
console.log('[useMagnifierZoom] Cannot recalculate zoom - invalid viewBoxWidth')
return
}
const uncappedZoom = uncappedAdaptiveZoomRef.current
const screenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: uncappedZoom,
})
console.log('[useMagnifierZoom] Pointer lock released - checking zoom cap:', {
uncappedZoom: uncappedZoom.toFixed(1),
screenPixelRatio: screenPixelRatio.toFixed(1),
threshold,
exceedsThreshold: isAboveThreshold(screenPixelRatio, threshold),
})
// Cap zoom if it exceeds threshold
if (isAboveThreshold(screenPixelRatio, threshold)) {
const maxZoom = calculateMaxZoomAtThreshold(threshold, magnifierWidth, svgRect.width)
const cappedZoom = Math.min(uncappedZoom, maxZoom)
console.log(
`[useMagnifierZoom] ✅ Capping zoom: ${uncappedZoom.toFixed(1)}×${cappedZoom.toFixed(1)}× (threshold: ${threshold} px/px)`
)
setTargetZoom(cappedZoom)
}
}
// When pointer lock is acquired, update target zoom to uncapped value
if (pointerLocked && uncappedAdaptiveZoomRef.current !== null) {
console.log(
`[useMagnifierZoom] Pointer lock acquired - using uncapped zoom: ${uncappedAdaptiveZoomRef.current.toFixed(1)}×`
)
setTargetZoom(uncappedAdaptiveZoomRef.current)
}
}, [pointerLocked, containerRef, svgRef, viewBox, threshold])
// Handle pause/resume at threshold
useEffect(() => {
const currentZoom = magnifierSpring.zoom.get()
const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01
console.log('[useMagnifierZoom] Animation effect:', {
currentZoom: currentZoom.toFixed(1),
targetZoom: targetZoom.toFixed(1),
zoomIsAnimating,
pointerLocked,
})
// Check if CURRENT zoom is at/above 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 = 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, threshold)
})()
// Check if TARGET zoom is at/above 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 = 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, threshold)
})()
console.log('[useMagnifierZoom] Threshold checks:', {
currentIsAtThreshold,
targetIsAtThreshold,
shouldPause: currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold,
})
// Pause if:
// - Currently at threshold AND
// - Animating toward higher zoom AND
// - Target is also at threshold
const shouldPause = currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold
if (shouldPause) {
console.log('[useMagnifierZoom] ⏸️ Pausing at threshold - waiting for precision mode')
magnifierApi.pause()
} else {
// Resume/update animation
if (currentIsAtThreshold && !targetIsAtThreshold) {
console.log('[useMagnifierZoom] ▶️ Resuming - target zoom is below threshold')
}
console.log('[useMagnifierZoom] 🎬 Starting/updating animation to:', targetZoom.toFixed(1))
magnifierApi.start({ zoom: targetZoom })
}
}, [
targetZoom,
pointerLocked,
viewBox,
threshold,
containerRef,
svgRef,
magnifierApi,
magnifierSpring.zoom,
])
return {
targetZoom,
setTargetZoom,
zoomSpring: magnifierSpring.zoom.get(),
getCurrentZoom: () => magnifierSpring.zoom.get(),
uncappedAdaptiveZoomRef,
}
}