From 60cf98e77a901c42210833c7b59350d28501eb52 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 30 Nov 2025 15:01:51 -0600 Subject: [PATCH] feat(know-your-world): improve mobile magnifier with adaptive zoom and select button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use findOptimalZoom for mobile (same algorithm as desktop) instead of hardcoded 2.5-4x - Keep magnifier visible after drag ends so user can confirm selection - Add green "Select ✓" button below magnifier for confirming region selection - Tap elsewhere on map to dismiss magnifier without selecting - Disable pull-to-refresh with touchAction: none and overscrollBehavior: none - Add defensive check for undefined includeSizes in filterRegionsBySizes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MapRenderer.tsx | 231 ++++++++++++++---- .../src/arcade-games/know-your-world/maps.ts | 6 +- 2 files changed, 191 insertions(+), 46 deletions(-) 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 2d7e8397..2aebafee 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 @@ -38,8 +38,6 @@ import type { FeedbackType } from '../utils/hotColdPhrases' import { getAdjustedMagnifiedDimensions, getMagnifierDimensions, - MAGNIFIER_SIZE_LARGE, - MAGNIFIER_SIZE_SMALL, } from '../utils/magnifierDimensions' import { calculateMaxZoomAtThreshold, @@ -2120,7 +2118,8 @@ export function MapRenderer({ if (cursorInMagnifier) { // Calculate leftover rectangle bounds (where magnifier can safely be positioned) const leftoverTop = SAFE_ZONE_MARGINS.top - const leftoverBottom = containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 + const leftoverBottom = + containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 const leftoverLeft = SAFE_ZONE_MARGINS.left + 20 const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20 @@ -2153,8 +2152,10 @@ export function MapRenderer({ const containerRect = containerRef.current.getBoundingClientRect() const svgRect = svgRef.current.getBoundingClientRect() // Calculate leftover rectangle dimensions for magnifier sizing - const leftoverWidthForCap = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeightForCap = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const leftoverWidthForCap = + containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeightForCap = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom const { width: magnifierWidth } = getMagnifierDimensions( leftoverWidthForCap, leftoverHeightForCap @@ -2258,15 +2259,36 @@ export function MapRenderer({ // Use adaptive zoom from region detection if available const detectionResult = detectRegions(cursorX, cursorY) - const { detectedSmallestSize, hasSmallRegion } = detectionResult + const { detectedRegions: detectedRegionObjects, detectedSmallestSize } = detectionResult - // For mobile, use a moderate fixed zoom or adapt based on regions - const mobileZoom = hasSmallRegion ? Math.min(4, MAX_ZOOM) : 2.5 - setTargetZoom(mobileZoom) + // Filter out found regions from zoom calculations (same as desktop) + const unfoundRegionObjects = detectedRegionObjects.filter( + (r) => !regionsFound.includes(r.id) + ) + + // Use adaptive zoom search utility to find optimal zoom (same algorithm as desktop) + const svgRect = svgRef.current.getBoundingClientRect() + const zoomSearchResult = findOptimalZoom({ + detectedRegions: unfoundRegionObjects, + detectedSmallestSize, + cursorX, + cursorY, + containerRect, + svgRect, + mapData, + svgElement: svgRef.current, + largestPieceSizesCache: largestPieceSizesRef.current, + maxZoom: MAX_ZOOM, + minZoom: 1, + pointerLocked: false, // Mobile never uses pointer lock + }) + + setTargetZoom(zoomSearchResult.zoom) // Calculate leftover rectangle dimensions (area not covered by UI elements) const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const leftoverHeight = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom // Get magnifier dimensions based on leftover rectangle (responsive to its aspect ratio) const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions( @@ -2276,7 +2298,8 @@ export function MapRenderer({ // Calculate leftover rectangle bounds (where magnifier can safely be positioned) const leftoverTop = SAFE_ZONE_MARGINS.top - const leftoverBottom = containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 + const leftoverBottom = + containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20 const leftoverLeft = SAFE_ZONE_MARGINS.left + 20 const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20 @@ -2296,20 +2319,38 @@ export function MapRenderer({ setTargetLeft(newLeft) } }, - [isMobileMapDragging, MOBILE_DRAG_THRESHOLD, detectRegions, MAX_ZOOM, getMagnifierDimensions] + [ + isMobileMapDragging, + MOBILE_DRAG_THRESHOLD, + detectRegions, + MAX_ZOOM, + getMagnifierDimensions, + regionsFound, + mapData, + ] ) + // Helper to dismiss the magnifier (used by tap-to-dismiss and after selection) + const dismissMagnifier = useCallback(() => { + setShowMagnifier(false) + setTargetOpacity(0) + setCursorPosition(null) + cursorPositionRef.current = null + }, []) + const handleMapTouchEnd = useCallback(() => { + const wasDragging = isMobileMapDragging mapTouchStartRef.current = null - if (isMobileMapDragging) { + + if (wasDragging) { setIsMobileMapDragging(false) - // Hide magnifier and clear cursor when drag ends - setShowMagnifier(false) - setTargetOpacity(0) - setCursorPosition(null) - cursorPositionRef.current = null + // Keep magnifier visible after drag ends - user can tap "Select" button or tap elsewhere to dismiss + // Don't hide magnifier or clear cursor - leave them in place for selection + } else if (showMagnifier && cursorPositionRef.current) { + // User tapped on map (not a drag) while magnifier is visible - dismiss the magnifier + dismissMagnifier() } - }, [isMobileMapDragging]) + }, [isMobileMapDragging, showMagnifier, dismissMagnifier]) // Mobile magnifier touch handlers - allow panning by dragging on the magnifier const handleMagnifierTouchStart = useCallback((e: React.TouchEvent) => { @@ -2503,6 +2544,27 @@ export function MapRenderer({ ] ) + // Helper to select the region at the crosshairs (center of magnifier view) + const selectRegionAtCrosshairs = useCallback(() => { + if (!cursorPositionRef.current || !svgRef.current || !containerRef.current) return + + // Run region detection at the current cursor position (center of magnifier) + const { regionUnderCursor } = detectRegions( + cursorPositionRef.current.x, + cursorPositionRef.current.y + ) + + if (regionUnderCursor && !celebration) { + const region = mapData.regions.find((r) => r.id === regionUnderCursor) + if (region) { + handleRegionClickWithCelebration(regionUnderCursor, region.name) + } + } + + // Dismiss magnifier after selection attempt + dismissMagnifier() + }, [detectRegions, mapData.regions, handleRegionClickWithCelebration, celebration, dismissMagnifier]) + return (
{ + e.stopPropagation() // Prevent triggering map touch end + selectRegionAtCrosshairs() + }} + style={{ + position: 'absolute', + // Position below the magnifier + top: magnifierSpring.top.to((t) => { + const containerRect = containerRef.current?.getBoundingClientRect() + if (!containerRect) return t + 200 + const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { height: magnifierHeight } = getMagnifierDimensions(leftoverWidth, leftoverHeight) + return t + magnifierHeight + 12 // 12px gap below magnifier + }), + left: magnifierSpring.left.to((l) => { + const containerRect = containerRef.current?.getBoundingClientRect() + if (!containerRect) return l + const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width: magnifierWidth } = getMagnifierDimensions(leftoverWidth, leftoverHeight) + return l + magnifierWidth / 2 - 60 // Center the 120px button under magnifier + }), + width: 120, + opacity: magnifierSpring.opacity, + zIndex: 101, + }} + className={css({ + padding: '12px 24px', + background: 'linear-gradient(135deg, #22c55e, #16a34a)', + border: 'none', + borderRadius: '12px', + color: 'white', + fontSize: '16px', + fontWeight: 'bold', + cursor: 'pointer', + boxShadow: '0 4px 12px rgba(34, 197, 94, 0.4)', + touchAction: 'none', + _active: { + transform: 'scale(0.95)', + }, + })} + > + Select ✓ + + )} + {/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */} {(() => { if (!showMagnifier || !cursorPosition || !svgRef.current || !containerRef.current) { @@ -3959,7 +4099,8 @@ export function MapRenderer({ // Calculate leftover rectangle dimensions (area not covered by UI elements) const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const leftoverHeight = + containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom // Get magnifier dimensions based on leftover rectangle (responsive to its aspect ratio) const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions( diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts index 18d4621f..0b94dba0 100644 --- a/apps/web/src/arcade-games/know-your-world/maps.ts +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -2722,7 +2722,11 @@ export function filterRegionsBySizes( mapId: 'world' | 'usa' = 'world' ): MapRegion[] { // If all sizes included, empty array, or undefined - return all regions - if (!includeSizes || includeSizes.length === 0 || includeSizes.length === ALL_REGION_SIZES.length) { + if ( + !includeSizes || + includeSizes.length === 0 || + includeSizes.length === ALL_REGION_SIZES.length + ) { return regions }