fix(know-your-world): stabilize mobile magnifier 1:1 touch tracking

Use empirical scale measurement with direct delta tracking instead of
closed-loop feedback approach. The feedback loop was causing instability
due to one-frame lag between error measurement and cursor updates.

The empirical approach measures actual pixels-per-SVG-unit using probe
circles, then applies frame-by-frame deltas with that scale factor.
This automatically accounts for zoom, aspect ratio, and letterboxing
without accumulating error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-04 16:36:26 -06:00
parent 39886e859c
commit ab30adda25
4 changed files with 210 additions and 81 deletions

View File

@ -507,6 +507,10 @@ export function MapRenderer({
// Refs for scale probe elements (for empirical 1:1 tracking measurement)
const scaleProbe1Ref = useRef<SVGCircleElement>(null)
const scaleProbe2Ref = useRef<SVGCircleElement>(null)
// Refs for closed-loop anchor probe tracking (1:1 touch tracking)
const anchorProbeRef = useRef<SVGCircleElement>(null)
const anchorSvgPositionRef = useRef<{ x: number; y: number } | null>(null)
const fingerStartRef = useRef<{ x: number; y: number } | null>(null)
// Where user tapped on magnifier
const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null)
@ -2356,6 +2360,9 @@ export function MapRenderer({
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
anchorProbeRef,
anchorSvgPositionRef,
fingerStartRef,
// Position & Animation (cursorPosition comes from state machine)
cursorPosition,
zoomSpring,
@ -2396,6 +2403,9 @@ export function MapRenderer({
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
anchorProbeRef,
anchorSvgPositionRef,
fingerStartRef,
cursorPosition,
zoomSpring,
magnifierSpring,

View File

@ -70,6 +70,12 @@ export interface MagnifierContextValue {
scaleProbe1Ref: RefObject<SVGCircleElement>
/** Scale probe 2 ref (for empirical scale measurement) */
scaleProbe2Ref: RefObject<SVGCircleElement>
/** Anchor probe ref (for closed-loop 1:1 tracking) */
anchorProbeRef: RefObject<SVGCircleElement>
/** Anchor SVG position - set on touch start, probe stays at this SVG coord */
anchorSvgPositionRef: React.MutableRefObject<{ x: number; y: number } | null>
/** Finger start position in screen coords - where finger was when anchor was placed */
fingerStartRef: React.MutableRefObject<{ x: number; y: number } | null>
// -------------------------------------------------------------------------
// Position & Animation
@ -193,6 +199,9 @@ export function MagnifierProvider({ children, value }: MagnifierProviderProps) {
value.cursorPositionRef,
value.scaleProbe1Ref,
value.scaleProbe2Ref,
value.anchorProbeRef,
value.anchorSvgPositionRef,
value.fingerStartRef,
// Position & Animation
value.cursorPosition,
value.zoomSpring,

View File

@ -90,6 +90,8 @@ export function MagnifierOverlay({
highZoomThreshold,
scaleProbe1Ref,
scaleProbe2Ref,
anchorProbeRef,
anchorSvgPositionRef,
} = useMagnifierContext()
// Distance between scale probes in SVG units (must match useEmpiricalScale.ts)
@ -310,6 +312,21 @@ export function MagnifierOverlay({
pointerEvents="none"
data-scale-probe="2"
/>
{/* Anchor probe for closed-loop 1:1 tracking */}
{/* Position is set on touch start and stays fixed in SVG coords */}
{/* We measure its screen position to solve for cursor movement */}
{anchorSvgPositionRef.current && (
<circle
ref={anchorProbeRef}
cx={anchorSvgPositionRef.current.x}
cy={anchorSvgPositionRef.current.y}
r={0.5}
fill="transparent"
stroke="none"
pointerEvents="none"
data-anchor-probe="true"
/>
)}
</>
)
})()}

View File

@ -20,12 +20,7 @@ import { getMagnifierDimensions } from '../../utils/magnifierDimensions'
import { useMapGameContext } from '../game'
import { getRenderedViewport } from '../labels'
import { useMagnifierContext } from './MagnifierContext'
import {
applyPanDelta,
calculateTouchMultiplier,
clampToSvgBounds,
parseViewBoxDimensions,
} from './panningMath'
import { calculateTouchMultiplier, clampToSvgBounds, parseViewBoxDimensions } from './panningMath'
// ============================================================================
// Constants
@ -130,6 +125,7 @@ export function useMagnifierTouchHandlers(
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
anchorSvgPositionRef,
isMagnifierExpanded,
setIsMagnifierExpanded,
getCurrentZoom,
@ -196,6 +192,20 @@ export function useMagnifierTouchHandlers(
return { pixelsPerSvgUnit, isValid: true }
}, [scaleProbe1Ref, scaleProbe2Ref, SCALE_PROBE_SVG_DISTANCE])
// -------------------------------------------------------------------------
// Direct Anchor Tracking
// -------------------------------------------------------------------------
/**
* Direct approach to 1:1 tracking:
* 1. On touch start, record anchor's INITIAL screen position
* 2. On touch move, measure finger delta from its start position
* 3. Apply inverse delta to cursor (in container coords via empirical scale)
*
* This avoids the feedback loop issue where measuring anchor error after
* cursor update causes instability.
*/
const anchorInitialScreenRef = useRef<{ x: number; y: number } | null>(null)
// -------------------------------------------------------------------------
// Refs for touch tracking (internal to this hook)
// -------------------------------------------------------------------------
@ -204,6 +214,8 @@ export function useMagnifierTouchHandlers(
const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null)
const pinchStartDistanceRef = useRef<number | null>(null)
const pinchStartZoomRef = useRef<number | null>(null)
// Track last zoom level to detect zoom changes and re-anchor
const lastZoomRef = useRef<number | null>(null)
// -------------------------------------------------------------------------
// Helper: Calculate distance between two touch points
@ -243,27 +255,83 @@ export function useMagnifierTouchHandlers(
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) {
// =======================================================================
// ANCHOR TRACKING SETUP (for empirical scale measurement)
// =======================================================================
// Calculate where the finger touch is in SVG coordinates
// This becomes the anchor position - a fixed point in SVG space that we'll
// try to keep under the finger as it moves
if (
magnifierRef.current &&
svgRef.current &&
containerRef.current &&
cursorPositionRef.current
) {
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
const magnifierRect = magnifierRef.current.getBoundingClientRect()
// Get viewport info for coordinate conversion
const viewBox = parseViewBoxDimensions(displayViewBox)
const viewport = getRenderedViewport(
svgRect,
parsedViewBox.x,
parsedViewBox.y,
parsedViewBox.width,
parsedViewBox.height
)
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 + parsedViewBox.x
const cursorSvgY =
(cursorPositionRef.current.y - svgOffsetY) / viewport.scale + parsedViewBox.y
// Calculate magnified viewBox dimensions
const currentZoom = getCurrentZoom()
const magnifiedWidth = parsedViewBox.width / currentZoom
const magnifiedHeight = parsedViewBox.height / currentZoom
// Where did the finger touch within the magnifier? (0-1 normalized)
const touchInMagnifierX = (touch.clientX - magnifierRect.left) / magnifierRect.width
const touchInMagnifierY = (touch.clientY - magnifierRect.top) / magnifierRect.height
// Convert to SVG coordinates within the magnified view
const touchSvgX = cursorSvgX - magnifiedWidth / 2 + touchInMagnifierX * magnifiedWidth
const touchSvgY = cursorSvgY - magnifiedHeight / 2 + touchInMagnifierY * magnifiedHeight
// Set anchor position - this SVG coordinate should stay under the finger
anchorSvgPositionRef.current = { x: touchSvgX, y: touchSvgY }
// Record the finger's initial screen position for delta tracking
// We'll use this to track how far the finger has moved from start
anchorInitialScreenRef.current = { x: touch.clientX, y: touch.clientY }
console.log('[MagnifierTouchStart] ANCHOR SETUP:', {
fingerScreen: { x: touch.clientX, y: touch.clientY },
touchInMagnifier: {
x: touchInMagnifierX.toFixed(2),
y: touchInMagnifierY.toFixed(2),
},
cursorSvg: { x: cursorSvgX.toFixed(1), y: cursorSvgY.toFixed(1) },
anchorSvg: { x: touchSvgX.toFixed(1), y: touchSvgY.toFixed(1) },
magnifiedSize: {
w: magnifiedWidth.toFixed(1),
h: magnifiedHeight.toFixed(1),
},
zoom: currentZoom.toFixed(2),
})
// Record tap position relative to magnifier for tap-to-select
magnifierTapPositionRef.current = {
x: touch.clientX - magnifierRect.left,
y: touch.clientY - magnifierRect.top,
}
console.log('[MagnifierTouchStart] Touch started on magnifier:', {
touchClient: { x: touch.clientX, y: touch.clientY },
magnifierRect: {
left: magnifierRect.left.toFixed(0),
top: magnifierRect.top.toFixed(0),
width: magnifierRect.width.toFixed(0),
height: magnifierRect.height.toFixed(0),
},
tapPositionInMagnifier: magnifierTapPositionRef.current,
cursorPosition: cursorPositionRef.current,
currentZoom: getCurrentZoom().toFixed(2),
isMagnifierExpanded,
})
// Initialize lastZoomRef for zoom change detection
lastZoomRef.current = currentZoom
}
// State machine handles dragging state via TOUCH_MOVE (transitions to magnifierPanning)
@ -276,8 +344,12 @@ export function useMagnifierTouchHandlers(
interaction,
setIsMagnifierExpanded,
magnifierRef,
svgRef,
containerRef,
cursorPositionRef,
isMagnifierExpanded,
anchorSvgPositionRef,
parsedViewBox,
displayViewBox,
]
)
@ -347,36 +419,69 @@ export function useMagnifierTouchHandlers(
magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY }
// =========================================================================
// EMPIRICAL SCALE MEASUREMENT for 1:1 Touch Tracking
// DIRECT DELTA TRACKING for True 1:1 Touch Tracking
// =========================================================================
// Instead of calculating through all transform layers, we measure the actual
// pixel-to-SVG ratio by comparing screen positions of probe elements.
// This is robust to any rendering pipeline changes.
// Simple approach that avoids feedback loops:
// 1. On touch start, record finger's initial screen position
// 2. On each move, calculate how far finger moved from START
// 3. Convert that screen delta to cursor delta using empirical scale
// 4. Apply inverse delta to cursor (dragging right moves view left)
//
// This uses the same "screen pixels per SVG unit" measurement but applies
// it to the delta from start, not frame-by-frame deltas. This is more
// stable because we're not accumulating errors.
const empiricalScale = measureEmpiricalScale()
const currentZoom = getCurrentZoom()
const anchorInitialScreen = anchorInitialScreenRef.current
let cursorDeltaX: number
let cursorDeltaY: number
// Get empirical scale - this tells us how many screen pixels = 1 SVG unit in magnifier
const empiricalScale = measureEmpiricalScale()
const viewBox = parseViewBoxDimensions(displayViewBox)
// touchMultiplier = how many container pixels to move per touch pixel
// For 1:1: when finger moves N pixels, content should move N pixels in magnifier
// Since we measure pixelsPerSvgUnit (magnifier pixels per SVG unit),
// and cursor position is in container coords (main SVG scale),
// we need: touchMultiplier = viewportScale / pixelsPerSvgUnit
// viewport scale = container pixels per SVG unit (for the main map, not magnifier)
const viewportScale =
svgRect.width / viewBox.width > svgRect.height / viewBox.height
? svgRect.height / viewBox.height
: svgRect.width / viewBox.width
// Default to calculated value if empirical measurement fails
let touchMultiplier: number
if (empiricalScale.isValid) {
// Empirical: finger moves in screen pixels, we need cursor delta in container pixels
// pixelsPerSvgUnit = screen pixels per SVG unit in magnifier
// viewportScale = container pixels per SVG unit in main map
// touchMultiplier = viewportScale / pixelsPerSvgUnit
touchMultiplier = viewportScale / empiricalScale.pixelsPerSvgUnit
if (empiricalScale.isValid && anchorInitialScreen) {
// EMPIRICAL APPROACH: Use measured scale for accurate conversion
//
// The user drags in screen pixels. We need to move the cursor in container pixels.
// - In the magnifier: 1 SVG unit = empiricalScale.pixelsPerSvgUnit screen pixels
// - In the main map: 1 SVG unit = viewportScale container pixels
//
// So: touchMultiplier = viewportScale / empiricalScale.pixelsPerSvgUnit
//
// When finger moves 1 screen pixel in magnifier:
// - That's (1 / pixelsPerSvgUnit) SVG units
// - Which is (viewportScale / pixelsPerSvgUnit) container pixels of cursor movement
const touchMultiplier = viewportScale / empiricalScale.pixelsPerSvgUnit
// Apply delta from this frame (deltaX, deltaY already calculated above)
// Inverted because dragging right should move cursor left (panning view)
cursorDeltaX = -deltaX * touchMultiplier
cursorDeltaY = -deltaY * touchMultiplier
// DEBUG: Log tracking (throttled)
const now = performance.now()
if (now - lastLogTimeRef.current > 200) {
lastLogTimeRef.current = now
console.log('[MagnifierPan] DIRECT delta tracking:', {
frameDelta: { x: deltaX.toFixed(1), y: deltaY.toFixed(1) },
touchMultiplier: touchMultiplier.toFixed(4),
cursorDelta: { x: cursorDeltaX.toFixed(2), y: cursorDeltaY.toFixed(2) },
empiricalPxPerSvg: empiricalScale.pixelsPerSvgUnit.toFixed(2),
viewportScale: viewportScale.toFixed(2),
zoom: currentZoom.toFixed(2),
})
}
} else {
// Fallback to old calculation
// FALLBACK: Calculate touch multiplier from dimensions
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
const leftoverHeight =
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
@ -399,50 +504,30 @@ export function useMagnifierTouchHandlers(
zoom: currentZoom,
}
)
touchMultiplier = touchMultiplierResult.multiplier
const touchMultiplier = touchMultiplierResult.multiplier
// Apply delta-based calculation (inverted because dragging right should move view left)
cursorDeltaX = -deltaX * touchMultiplier
cursorDeltaY = -deltaY * touchMultiplier
const now = performance.now()
if (now - lastLogTimeRef.current > 500) {
lastLogTimeRef.current = now
console.log('[MagnifierPan] FALLBACK calculated tracking:', {
touchDelta: { x: deltaX.toFixed(1), y: deltaY.toFixed(1) },
touchMultiplier: touchMultiplier.toFixed(4),
zoom: currentZoom.toFixed(2),
})
}
}
// DEBUG: Log empirical scale measurement (throttled to once per 500ms)
const now = performance.now()
if (now - lastLogTimeRef.current > 500) {
lastLogTimeRef.current = now
console.log('[MagnifierPan] EMPIRICAL 1:1 tracking:', {
// Touch delta
touchDelta: { x: deltaX.toFixed(1), y: deltaY.toFixed(1) },
// Empirical measurement
empirical: {
isValid: empiricalScale.isValid,
pixelsPerSvgUnit: empiricalScale.pixelsPerSvgUnit.toFixed(4),
},
// Multiplier being used
touchMultiplier: touchMultiplier.toFixed(4),
viewportScale: viewportScale.toFixed(4),
// Current zoom
zoom: currentZoom.toFixed(2),
// Cursor movement (in container pixels)
cursorMovement: {
expected: { x: (-deltaX).toFixed(1), y: (-deltaY).toFixed(1) },
actual: {
x: (-deltaX * touchMultiplier).toFixed(1),
y: (-deltaY * touchMultiplier).toFixed(1),
},
},
})
}
// Apply pan delta and clamp to SVG bounds
// Apply cursor delta and clamp to SVG bounds
const svgOffsetX = svgRect.left - containerRect.left
const svgOffsetY = svgRect.top - containerRect.top
const newCursor = applyPanDelta(
cursorPositionRef.current,
{ x: deltaX, y: deltaY },
touchMultiplier
)
const newCursor = {
x: cursorPositionRef.current.x + cursorDeltaX,
y: cursorPositionRef.current.y + cursorDeltaY,
}
const clamped = clampToSvgBounds(newCursor, {
left: svgOffsetX,
top: svgOffsetY,
@ -516,6 +601,7 @@ export function useMagnifierTouchHandlers(
pointerLocked: false, // Mobile never uses pointer lock
})
// Set the target zoom (no re-anchoring needed with direct delta tracking)
setTargetZoom(zoomSearchResult.zoom)
// Broadcast cursor update to other players (if in multiplayer)
@ -559,11 +645,13 @@ export function useMagnifierTouchHandlers(
svgRef,
containerRef,
cursorPositionRef,
anchorSvgPositionRef,
// Note: hoveredRegion and setHoveredRegion removed - state machine is authoritative
hotColdEnabledRef,
largestPieceSizesRef,
parsedViewBox,
interaction,
magnifierRef,
]
)
@ -620,6 +708,10 @@ export function useMagnifierTouchHandlers(
magnifierDidMoveRef.current = false
magnifierTapPositionRef.current = null
// Clear anchor tracking refs
anchorSvgPositionRef.current = null
anchorInitialScreenRef.current = null
// 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
@ -691,6 +783,7 @@ export function useMagnifierTouchHandlers(
containerRef,
cursorPositionRef,
parsedViewBox,
anchorSvgPositionRef,
]
)