fix: resolve auto zoom freeze and stuck zoom issues

Fix two critical bugs in magnifier zoom system:

1. **Render loop causing freeze**: Memoized pointer lock callbacks with useCallback
   - onLockAcquired and onLockReleased were recreated every render
   - This caused usePointerLock effect to constantly tear down/reattach event listeners
   - Fixed by wrapping callbacks in useCallback with empty deps

2. **Zoom stuck at high values**: Always resume spring before starting new animations
   - Spring could get paused at threshold waiting for precision mode
   - When conditions changed, spring stayed paused because resume() wasn't called
   - Added explicit magnifierApi.resume() before all start() calls
   - Added immediate snap for large zoom differences (>100x) to prevent slow animations

3. **Debug visualization**: Added detection box overlay showing:
   - 50px yellow dashed box around cursor
   - List of detected regions with sizes
   - Current vs target zoom levels
   - Helps diagnose auto zoom behavior

4. **Documentation**: Added CRITICAL section to .claude/CLAUDE.md about checking imports
   - Mandate reading imports before using React hooks
   - Prevents missing import errors that break the app

Related files:
- MapRenderer.tsx: Memoized callbacks, added debug overlay
- useMagnifierZoom.ts: Added resume() call and immediate snap logic
- .claude/CLAUDE.md: Added import checking requirement

🤖 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 09:59:44 -06:00
parent c8018b1de3
commit 0aee60d8d1
3 changed files with 210 additions and 29 deletions

View File

@ -1,5 +1,32 @@
# Claude Code Instructions for apps/web
## CRITICAL: Always Check Imports Before Using React Hooks
**MANDATORY: Before using ANY React hook or function, verify it's imported.**
**The Process (EVERY TIME):**
1. Read the imports section at the top of the file (lines 1-20)
2. Check if the hook/function you need is already imported
3. If missing, add it to the import statement IN THE SAME EDIT as your code
4. Do NOT write code that uses a hook without checking imports first
**Common mistakes:**
- ❌ Using `useCallback` without checking if it's imported
- ❌ Using `useMemo` without checking if it's imported
- ❌ Using `useRef` without checking if it's imported
- ✅ Read imports → verify → add if needed → write code
**Why this matters:**
- Missing imports break the app immediately
- User has to reload and loses state
- Wastes time debugging trivial import errors
- Shows lack of attention to detail
**If you forget this, you will:**
- Break the user's development flow
- Lose reproduction state for bugs being debugged
- Annoy the user with preventable errors
## CRITICAL: Documentation Graph Requirement
**ALL documentation must be reachable from the main README via a linked path.**

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useMemo, useRef, useEffect } from 'react'
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { useSpring, useSpringRef, animated } from '@react-spring/web'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
@ -200,28 +200,33 @@ export function MapRenderer({
const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 })
const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false)
// Memoize pointer lock callbacks to prevent render loop
const handleLockAcquired = useCallback(() => {
// Save initial cursor position
if (cursorPositionRef.current) {
initialCapturePositionRef.current = { ...cursorPositionRef.current }
console.log(
'[Pointer Lock] 📍 Saved initial capture position:',
initialCapturePositionRef.current
)
}
// Note: Zoom update now handled by useMagnifierZoom hook
}, [])
const handleLockReleased = useCallback(() => {
console.log('[Pointer Lock] 🔓 RELEASED - Starting cleanup')
// Reset cursor squish
setCursorSquish({ x: 1, y: 1 })
setIsReleasingPointerLock(false)
// Note: Zoom recalculation now handled by useMagnifierZoom hook
}, [])
// Pointer lock hook (needed by zoom hook)
const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({
containerRef,
onLockAcquired: () => {
// Save initial cursor position
if (cursorPositionRef.current) {
initialCapturePositionRef.current = { ...cursorPositionRef.current }
console.log(
'[Pointer Lock] 📍 Saved initial capture position:',
initialCapturePositionRef.current
)
}
// Note: Zoom update now handled by useMagnifierZoom hook
},
onLockReleased: () => {
console.log('[Pointer Lock] 🔓 RELEASED - Starting cleanup')
// Reset cursor squish
setCursorSquish({ x: 1, y: 1 })
setIsReleasingPointerLock(false)
// Note: Zoom recalculation now handled by useMagnifierZoom hook
},
onLockAcquired: handleLockAcquired,
onLockReleased: handleLockReleased,
})
// Magnifier zoom hook
@ -317,9 +322,54 @@ export function MapRenderer({
if (!pointerLocked) {
requestPointerLock()
console.log('[Pointer Lock] 🔒 Silently requested (user clicked map)')
return // Don't process region click on the first click that requests lock
}
// Let region clicks still work (they have their own onClick handlers)
// When pointer lock is active, browser doesn't deliver click events to SVG children
// We need to manually detect which region is under the cursor
if (pointerLocked && cursorPositionRef.current && containerRef.current && svgRef.current) {
const { x: cursorX, y: cursorY } = cursorPositionRef.current
console.log('[CLICK] Pointer lock click at cursor position:', { cursorX, cursorY })
// Use the same detection logic as hover tracking (50px detection box)
// This checks the main map SVG at the cursor position
const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY)
console.log('[CLICK] Detection results:', {
detectedRegions: detectedRegions.map((r) => r.id),
regionUnderCursor,
detectedCount: detectedRegions.length,
})
if (regionUnderCursor) {
// Find the region data to get the name
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
if (region) {
console.log('[CLICK] Detected region under cursor:', {
regionId: regionUnderCursor,
regionName: region.name,
})
onRegionClick(regionUnderCursor, region.name)
} else {
console.log('[CLICK] Region ID found but not in mapData:', regionUnderCursor)
}
} else if (detectedRegions.length > 0) {
// If no region directly under cursor, use the closest detected region
const closestRegion = detectedRegions[0] // Already sorted by distance
const region = mapData.regions.find((r) => r.id === closestRegion.id)
if (region) {
console.log('[CLICK] Using closest detected region:', {
regionId: closestRegion.id,
regionName: region.name,
distance: closestRegion.distanceToCenter?.toFixed(2),
})
onRegionClick(closestRegion.id, region.name)
}
} else {
console.log('[CLICK] No regions detected at cursor position')
}
}
}
// Animated spring values for smooth transitions
@ -1936,6 +1986,81 @@ export function MapRenderer({
})()}
</animated.div>
)}
{/* Debug: Auto zoom detection visualization */}
{cursorPosition && containerRef.current && (
<>
{/* Detection box - 50px box around cursor */}
<div
style={{
position: 'absolute',
left: `${cursorPosition.x - 25}px`,
top: `${cursorPosition.y - 25}px`,
width: '50px',
height: '50px',
border: '2px dashed yellow',
pointerEvents: 'none',
zIndex: 150,
}}
/>
{/* Detection info overlay - top left corner */}
{(() => {
const { detectedRegions, hasSmallRegion, detectedSmallestSize } = detectRegions(
cursorPosition.x,
cursorPosition.y
)
return (
<div
style={{
position: 'absolute',
top: '10px',
left: '10px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace',
pointerEvents: 'none',
zIndex: 150,
maxWidth: '300px',
}}
>
<div>
<strong>Detection Box (50px)</strong>
</div>
<div>Regions detected: {detectedRegions.length}</div>
<div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div>
<div>
Smallest size: {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
</div>
<div style={{ marginTop: '8px' }}>
<strong>Detected Regions:</strong>
</div>
{detectedRegions.slice(0, 5).map((region) => (
<div key={region.id} style={{ fontSize: '10px', marginLeft: '8px' }}>
{region.id}: {region.pixelWidth.toFixed(1)}×{region.pixelHeight.toFixed(1)}px
{region.isVerySmall ? ' (SMALL)' : ''}
</div>
))}
{detectedRegions.length > 5 && (
<div style={{ fontSize: '10px', marginLeft: '8px', color: '#888' }}>
...and {detectedRegions.length - 5} more
</div>
)}
<div style={{ marginTop: '8px' }}>
<strong>Current Zoom:</strong> {getCurrentZoom().toFixed(1)}×
</div>
<div>
<strong>Target Zoom:</strong> {targetZoom.toFixed(1)}×
</div>
</div>
)
})()}
</>
)}
</div>
)
}

View File

@ -141,8 +141,8 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
const currentZoom = magnifierSpring.zoom.get()
const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01
// Check if CURRENT zoom is at/above threshold (zoom is capped)
let currentScreenPixelRatio = 0
const currentIsAtThreshold =
!pointerLocked &&
containerRef.current &&
@ -156,17 +156,18 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
const screenPixelRatio = calculateScreenPixelRatio({
currentScreenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: currentZoom,
})
return isAboveThreshold(screenPixelRatio, threshold)
return isAboveThreshold(currentScreenPixelRatio, threshold)
})()
// Check if TARGET zoom is at/above threshold
let targetScreenPixelRatio = 0
const targetIsAtThreshold =
!pointerLocked &&
containerRef.current &&
@ -180,33 +181,61 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
const screenPixelRatio = calculateScreenPixelRatio({
targetScreenPixelRatio = calculateScreenPixelRatio({
magnifierWidth,
viewBoxWidth,
svgWidth: svgRect.width,
zoom: targetZoom,
})
return isAboveThreshold(screenPixelRatio, threshold)
return isAboveThreshold(targetScreenPixelRatio, threshold)
})()
// Pause if:
// - Currently at threshold AND
// - Animating toward higher zoom AND
// - Target is also at threshold
const shouldPause = currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold
console.log('[useMagnifierZoom] Effect running:', {
currentZoom: currentZoom.toFixed(1),
targetZoom: targetZoom.toFixed(1),
currentScreenPixelRatio: currentScreenPixelRatio.toFixed(1),
targetScreenPixelRatio: targetScreenPixelRatio.toFixed(1),
threshold,
zoomIsAnimating,
currentIsAtThreshold,
targetIsAtThreshold,
pointerLocked,
shouldPause,
})
if (shouldPause) {
console.log('[useMagnifierZoom] ⏸️ Pausing at threshold - waiting for precision mode')
magnifierApi.pause()
} else {
// Resume/update animation
// CRITICAL: Always resume first in case spring was paused
magnifierApi.resume()
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 })
// If current zoom is very far from target (>100× difference), snap immediately
// This prevents slow animations when recovering from stuck states
const zoomDifference = Math.abs(currentZoom - targetZoom)
const shouldSnapImmediate = zoomDifference > 100
if (shouldSnapImmediate) {
console.log(
`[useMagnifierZoom] ⚡ Snapping immediately from ${currentZoom.toFixed(1)}× to ${targetZoom.toFixed(1)}× (diff: ${zoomDifference.toFixed(1)}×)`
)
magnifierApi.start({ zoom: targetZoom, immediate: true })
} else {
console.log('[useMagnifierZoom] 🎬 Starting/updating animation to:', targetZoom.toFixed(1))
magnifierApi.start({ zoom: targetZoom })
}
}
}, [
targetZoom, // Effect runs when target zoom changes