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:
@@ -1,5 +1,32 @@
|
|||||||
# Claude Code Instructions for apps/web
|
# 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
|
## CRITICAL: Documentation Graph Requirement
|
||||||
|
|
||||||
**ALL documentation must be reachable from the main README via a linked path.**
|
**ALL documentation must be reachable from the main README via a linked path.**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { useSpring, useSpringRef, animated } from '@react-spring/web'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
@@ -200,28 +200,33 @@ export function MapRenderer({
|
|||||||
const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 })
|
const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 })
|
||||||
const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false)
|
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)
|
// Pointer lock hook (needed by zoom hook)
|
||||||
const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({
|
const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({
|
||||||
containerRef,
|
containerRef,
|
||||||
onLockAcquired: () => {
|
onLockAcquired: handleLockAcquired,
|
||||||
// Save initial cursor position
|
onLockReleased: handleLockReleased,
|
||||||
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
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Magnifier zoom hook
|
// Magnifier zoom hook
|
||||||
@@ -317,9 +322,54 @@ export function MapRenderer({
|
|||||||
if (!pointerLocked) {
|
if (!pointerLocked) {
|
||||||
requestPointerLock()
|
requestPointerLock()
|
||||||
console.log('[Pointer Lock] 🔒 Silently requested (user clicked map)')
|
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
|
// Animated spring values for smooth transitions
|
||||||
@@ -1936,6 +1986,81 @@ export function MapRenderer({
|
|||||||
})()}
|
})()}
|
||||||
</animated.div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
|||||||
const currentZoom = magnifierSpring.zoom.get()
|
const currentZoom = magnifierSpring.zoom.get()
|
||||||
const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01
|
const zoomIsAnimating = Math.abs(currentZoom - targetZoom) > 0.01
|
||||||
|
|
||||||
|
|
||||||
// Check if CURRENT zoom is at/above threshold (zoom is capped)
|
// Check if CURRENT zoom is at/above threshold (zoom is capped)
|
||||||
|
let currentScreenPixelRatio = 0
|
||||||
const currentIsAtThreshold =
|
const currentIsAtThreshold =
|
||||||
!pointerLocked &&
|
!pointerLocked &&
|
||||||
containerRef.current &&
|
containerRef.current &&
|
||||||
@@ -156,17 +156,18 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
|||||||
|
|
||||||
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
|
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
|
||||||
|
|
||||||
const screenPixelRatio = calculateScreenPixelRatio({
|
currentScreenPixelRatio = calculateScreenPixelRatio({
|
||||||
magnifierWidth,
|
magnifierWidth,
|
||||||
viewBoxWidth,
|
viewBoxWidth,
|
||||||
svgWidth: svgRect.width,
|
svgWidth: svgRect.width,
|
||||||
zoom: currentZoom,
|
zoom: currentZoom,
|
||||||
})
|
})
|
||||||
|
|
||||||
return isAboveThreshold(screenPixelRatio, threshold)
|
return isAboveThreshold(currentScreenPixelRatio, threshold)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// Check if TARGET zoom is at/above threshold
|
// Check if TARGET zoom is at/above threshold
|
||||||
|
let targetScreenPixelRatio = 0
|
||||||
const targetIsAtThreshold =
|
const targetIsAtThreshold =
|
||||||
!pointerLocked &&
|
!pointerLocked &&
|
||||||
containerRef.current &&
|
containerRef.current &&
|
||||||
@@ -180,33 +181,61 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
|||||||
|
|
||||||
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
|
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return false
|
||||||
|
|
||||||
const screenPixelRatio = calculateScreenPixelRatio({
|
targetScreenPixelRatio = calculateScreenPixelRatio({
|
||||||
magnifierWidth,
|
magnifierWidth,
|
||||||
viewBoxWidth,
|
viewBoxWidth,
|
||||||
svgWidth: svgRect.width,
|
svgWidth: svgRect.width,
|
||||||
zoom: targetZoom,
|
zoom: targetZoom,
|
||||||
})
|
})
|
||||||
|
|
||||||
return isAboveThreshold(screenPixelRatio, threshold)
|
return isAboveThreshold(targetScreenPixelRatio, threshold)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
||||||
// Pause if:
|
// Pause if:
|
||||||
// - Currently at threshold AND
|
// - Currently at threshold AND
|
||||||
// - Animating toward higher zoom AND
|
// - Animating toward higher zoom AND
|
||||||
// - Target is also at threshold
|
// - Target is also at threshold
|
||||||
const shouldPause = currentIsAtThreshold && zoomIsAnimating && targetIsAtThreshold
|
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) {
|
if (shouldPause) {
|
||||||
console.log('[useMagnifierZoom] ⏸️ Pausing at threshold - waiting for precision mode')
|
console.log('[useMagnifierZoom] ⏸️ Pausing at threshold - waiting for precision mode')
|
||||||
magnifierApi.pause()
|
magnifierApi.pause()
|
||||||
} else {
|
} else {
|
||||||
// Resume/update animation
|
// Resume/update animation
|
||||||
|
// CRITICAL: Always resume first in case spring was paused
|
||||||
|
magnifierApi.resume()
|
||||||
|
|
||||||
if (currentIsAtThreshold && !targetIsAtThreshold) {
|
if (currentIsAtThreshold && !targetIsAtThreshold) {
|
||||||
console.log('[useMagnifierZoom] ▶️ Resuming - target zoom is below threshold')
|
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
|
targetZoom, // Effect runs when target zoom changes
|
||||||
|
|||||||
Reference in New Issue
Block a user