From aee5f21ecc8982115e0012c59011dd8b342d5ca1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 26 Nov 2025 14:49:21 -0600 Subject: [PATCH] fix(know-your-world): improve mobile magnifier touch controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix slow panning: Use 1:1 touch multiplier instead of precision mode multiplier. Content now follows finger directly when dragging. - Fix tap-to-select: Convert tap position on magnifier to SVG coordinates and detect region at that position. Previously only detected region at magnifier center, now detects where user actually tapped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MapRenderer.tsx | 109 ++++++++++++++---- 1 file changed, 87 insertions(+), 22 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 7687b70e..ff83c515 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 @@ -427,6 +427,8 @@ export function MapRenderer({ const [isMagnifierDragging, setIsMagnifierDragging] = useState(false) const magnifierTouchStartRef = useRef<{ x: number; y: number } | null>(null) const magnifierDidMoveRef = useRef(false) // Track if user actually dragged (vs just tapped) + const magnifierRef = useRef(null) // Ref to magnifier element for tap position calculation + const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null) // Where user tapped on magnifier // Give up reveal animation state const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value @@ -1949,6 +1951,16 @@ export function MapRenderer({ const touch = e.touches[0] magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } magnifierDidMoveRef.current = false // Reset movement tracking + + // Record tap position relative to magnifier for tap-to-select + if (magnifierRef.current) { + const magnifierRect = magnifierRef.current.getBoundingClientRect() + magnifierTapPositionRef.current = { + x: touch.clientX - magnifierRect.left, + y: touch.clientY - magnifierRect.top, + } + } + setIsMagnifierDragging(true) e.preventDefault() // Prevent scrolling }, []) @@ -1971,16 +1983,22 @@ export function MapRenderer({ // Update start position for next move magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } - // Apply movement multiplier (same as pointer lock mode) - const currentMultiplier = magnifierSpring.movementMultiplier.get() + // For touch panning, use 1:1 mapping so the content follows the finger exactly. + // + // The cursor position controls where the magnifier is centered on the main map. + // Moving the cursor by 1 screen pixel shifts what's shown in the magnifier. + // + // For 1:1 feel: when user drags N pixels, move cursor by N pixels. + // This makes the content appear to move with the finger at the zoom level. + const touchMultiplier = 1.0 // Invert the delta - dragging right on magnifier should show content to the right // (which means moving the cursor right in the map coordinate space) // Actually, dragging the "paper" under the magnifier means: // - Drag finger right = paper moves right = magnifier shows what was to the LEFT // - So we SUBTRACT the delta to move the cursor in the opposite direction - const newCursorX = cursorPositionRef.current.x - deltaX * currentMultiplier - const newCursorY = cursorPositionRef.current.y - deltaY * currentMultiplier + const newCursorX = cursorPositionRef.current.x - deltaX * touchMultiplier + const newCursorY = cursorPositionRef.current.y - deltaY * touchMultiplier // Clamp to SVG bounds const containerRect = containerRef.current.getBoundingClientRect() @@ -2023,7 +2041,6 @@ export function MapRenderer({ }, [ isMagnifierDragging, - magnifierSpring.movementMultiplier, detectRegions, onCursorUpdate, gameMode, @@ -2036,33 +2053,80 @@ export function MapRenderer({ const handleMagnifierTouchEnd = useCallback( (e: React.TouchEvent) => { // Check if this was a tap (no significant movement) vs a drag - // If the user just tapped on the magnifier, select the region under cursor + // If the user just tapped on the magnifier, select the region at the tap position const didMove = magnifierDidMoveRef.current + const tapPosition = magnifierTapPositionRef.current setIsMagnifierDragging(false) magnifierTouchStartRef.current = null magnifierDidMoveRef.current = false + magnifierTapPositionRef.current = null - // If there was a changed touch that ended, check if it's a tap - if (e.changedTouches.length === 1 && cursorPositionRef.current) { - // Run region detection at current position - const { regionUnderCursor } = detectRegions( - cursorPositionRef.current.x, - cursorPositionRef.current.y - ) + // If there was a changed touch that ended and it wasn't a drag, check for tap-to-select + if (e.changedTouches.length === 1 && !didMove && tapPosition) { + // Convert tap position on magnifier to SVG coordinates + if ( + magnifierRef.current && + svgRef.current && + containerRef.current && + cursorPositionRef.current + ) { + const magnifierRect = magnifierRef.current.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() + const svgRect = svgRef.current.getBoundingClientRect() - // If we have a region and this wasn't a significant drag, trigger selection - // We rely on the touch move handler to have already updated cursor position - // If the user dragged significantly, don't select (they're navigating, not selecting) - if (regionUnderCursor && !didMove) { - const region = mapData.regions.find((r) => r.id === regionUnderCursor) - if (region) { - console.log('[Touch] Tapped on magnifier to select region:', regionUnderCursor) - onRegionClick(regionUnderCursor, region.name) + // Get the current zoom level + const currentZoom = zoomSpring.get() + + // Parse the main map viewBox + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxX = viewBoxParts[0] || 0 + const viewBoxY = viewBoxParts[1] || 0 + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 1000 + + // Get viewport info for coordinate conversion + const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH) + const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX + const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY + + // Current cursor position in SVG coordinates (center of magnifier view) + const cursorSvgX = + (cursorPositionRef.current.x - svgOffsetX) / viewport.scale + viewBoxX + const cursorSvgY = + (cursorPositionRef.current.y - svgOffsetY) / viewport.scale + viewBoxY + + // Magnifier viewBox dimensions + const magnifiedWidth = viewBoxW / currentZoom + const magnifiedHeight = viewBoxH / currentZoom + + // Convert tap position (relative to magnifier) to SVG coordinates + // Tap at (0,0) is top-left of magnifier = cursorSvg - magnifiedSize/2 + // Tap at (magnifierWidth, magnifierHeight) is bottom-right = cursorSvg + magnifiedSize/2 + const tapSvgX = + cursorSvgX - magnifiedWidth / 2 + (tapPosition.x / magnifierRect.width) * magnifiedWidth + const tapSvgY = + cursorSvgY - + magnifiedHeight / 2 + + (tapPosition.y / magnifierRect.height) * magnifiedHeight + + // Convert SVG coordinates back to container coordinates for region detection + const tapContainerX = (tapSvgX - viewBoxX) * viewport.scale + svgOffsetX + const tapContainerY = (tapSvgY - viewBoxY) * viewport.scale + svgOffsetY + + // Run region detection at the tap position + const { regionUnderCursor } = detectRegions(tapContainerX, tapContainerY) + + if (regionUnderCursor) { + const region = mapData.regions.find((r) => r.id === regionUnderCursor) + if (region) { + console.log('[Touch] Tapped on magnifier to select region:', regionUnderCursor) + onRegionClick(regionUnderCursor, region.name) + } } } } }, - [detectRegions, mapData.regions, onRegionClick] + [detectRegions, mapData.regions, onRegionClick, displayViewBox, zoomSpring] ) return ( @@ -2695,6 +2759,7 @@ export function MapRenderer({ return (