diff --git a/apps/web/.claude/CLAUDE.md b/apps/web/.claude/CLAUDE.md index 55498ff5..e05ba982 100644 --- a/apps/web/.claude/CLAUDE.md +++ b/apps/web/.claude/CLAUDE.md @@ -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.** diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 416f9621..f663d6a8 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -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({ })()} )} + + {/* Debug: Auto zoom detection visualization */} + {cursorPosition && containerRef.current && ( + <> + {/* Detection box - 50px box around cursor */} +
+ + {/* Detection info overlay - top left corner */} + {(() => { + const { detectedRegions, hasSmallRegion, detectedSmallestSize } = detectRegions( + cursorPosition.x, + cursorPosition.y + ) + + return ( +
+
+ Detection Box (50px) +
+
Regions detected: {detectedRegions.length}
+
Has small region: {hasSmallRegion ? 'YES' : 'NO'}
+
+ Smallest size: {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`} +
+
+ Detected Regions: +
+ {detectedRegions.slice(0, 5).map((region) => ( +
+ • {region.id}: {region.pixelWidth.toFixed(1)}×{region.pixelHeight.toFixed(1)}px + {region.isVerySmall ? ' (SMALL)' : ''} +
+ ))} + {detectedRegions.length > 5 && ( +
+ ...and {detectedRegions.length - 5} more +
+ )} +
+ Current Zoom: {getCurrentZoom().toFixed(1)}× +
+
+ Target Zoom: {targetZoom.toFixed(1)}× +
+
+ ) + })()} + + )}
) } diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts b/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts index 232b1bf3..288b07ee 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/useMagnifierZoom.ts @@ -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