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:
parent
c8018b1de3
commit
0aee60d8d1
|
|
@ -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.**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue