feat(know-your-world): implement empirical scale measurement for 1:1 magnifier tracking

Replace calculated touch multiplier with empirical measurement approach:
- Add invisible probe circles in magnifier SVG at known SVG coordinates (100 units apart)
- Measure actual screen pixel distance between probes via getBoundingClientRect()
- Calculate pixelsPerSvgUnit from measured distance / known SVG distance
- Use empirical scale for touch multiplier: viewportScale / pixelsPerSvgUnit
- Falls back to calculated method if measurement fails

This approach is robust to rendering pipeline changes since it measures
what's actually on screen rather than calculating through transform layers.

🤖 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 14:27:20 -06:00
parent e712fcbcb7
commit 39886e859c
9 changed files with 369 additions and 50 deletions

View File

@ -19,18 +19,11 @@ import {
} from '../features/interaction'
import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels'
import {
applyPanDelta,
calculateTouchMultiplier,
clampToSvgBounds,
getAdjustedMagnifiedDimensions,
getMagnifierDimensions,
type MagnifierContextValue,
MagnifierCrosshair,
MagnifierOverlayWithHandlers,
MagnifierPixelGrid,
MagnifierProvider,
MagnifierRegions,
parseViewBoxDimensions,
type UseMagnifierTouchHandlersOptions,
useMagnifierState,
useMagnifierStyle,
@ -51,7 +44,6 @@ import { usePointerLock } from '../hooks/usePointerLock'
import { useRegionDetection } from '../hooks/useRegionDetection'
import { useHasRegionHint, useRegionHint } from '../hooks/useRegionHint'
import { useSpeakHint } from '../hooks/useSpeakHint'
import { getRegionColor, getRegionStroke } from '../mapColors'
import {
ASSISTANCE_LEVELS,
calculateFitCropViewBox,
@ -67,7 +59,7 @@ import type { HintMap } from '../messages'
import { useKnowYourWorld } from '../Provider'
import type { MapData, MapRegion } from '../types'
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
import { CELEBRATION_TIMING, classifyCelebration } from '../utils/celebration'
import { classifyCelebration } from '../utils/celebration'
import {
calculateMaxZoomAtThreshold,
calculateScreenPixelRatio,
@ -512,6 +504,9 @@ export function MapRenderer({
// Ref to magnifier element for tap position calculation
const magnifierRef = useRef<HTMLDivElement>(null)
// Refs for scale probe elements (for empirical 1:1 tracking measurement)
const scaleProbe1Ref = useRef<SVGCircleElement>(null)
const scaleProbe2Ref = useRef<SVGCircleElement>(null)
// Where user tapped on magnifier
const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null)
@ -2359,6 +2354,8 @@ export function MapRenderer({
svgRef,
magnifierRef,
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
// Position & Animation (cursorPosition comes from state machine)
cursorPosition,
zoomSpring,
@ -2397,6 +2394,8 @@ export function MapRenderer({
svgRef,
magnifierRef,
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
cursorPosition,
zoomSpring,
magnifierSpring,

View File

@ -15,8 +15,8 @@
'use client'
import { createContext, useContext, useMemo, type ReactNode, type RefObject } from 'react'
import type { SpringValue } from '@react-spring/web'
import { createContext, type ReactNode, type RefObject, useContext, useMemo } from 'react'
import type { UseInteractionStateMachineReturn } from '../interaction'
@ -66,6 +66,10 @@ export interface MagnifierContextValue {
magnifierRef: RefObject<HTMLDivElement>
/** Cursor position ref (mutable) */
cursorPositionRef: React.MutableRefObject<{ x: number; y: number } | null>
/** Scale probe 1 ref (for empirical scale measurement) */
scaleProbe1Ref: RefObject<SVGCircleElement>
/** Scale probe 2 ref (for empirical scale measurement) */
scaleProbe2Ref: RefObject<SVGCircleElement>
// -------------------------------------------------------------------------
// Position & Animation
@ -187,6 +191,8 @@ export function MagnifierProvider({ children, value }: MagnifierProviderProps) {
value.svgRef,
value.magnifierRef,
value.cursorPositionRef,
value.scaleProbe1Ref,
value.scaleProbe2Ref,
// Position & Animation
value.cursorPosition,
value.zoomSpring,

View File

@ -9,7 +9,7 @@
'use client'
import { memo, type TouchEvent as ReactTouchEvent, type MouseEvent as ReactMouseEvent } from 'react'
import { memo, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
// ============================================================================
// Types

View File

@ -35,7 +35,6 @@ import { MagnifierControls } from './MagnifierControls'
import { MagnifierCrosshair } from './MagnifierCrosshair'
import { MagnifierPixelGrid } from './MagnifierPixelGrid'
import { MagnifierRegions } from './MagnifierRegions'
import type { CrosshairStyle as HeatCrosshairStyle } from './types'
// ============================================================================
// Types
@ -89,8 +88,13 @@ export function MagnifierOverlay({
precisionCalcs,
getCurrentZoom,
highZoomThreshold,
scaleProbe1Ref,
scaleProbe2Ref,
} = useMagnifierContext()
// Distance between scale probes in SVG units (must match useEmpiricalScale.ts)
const SCALE_PROBE_DISTANCE = 100
const {
mapData,
regionsFound,
@ -261,7 +265,7 @@ export function MagnifierOverlay({
showOutline={showOutline}
/>
{/* Crosshair at center position */}
{/* Crosshair at center position + Scale probes for empirical measurement */}
{(() => {
const viewport = getRenderedViewport(
svgRect,
@ -276,13 +280,37 @@ export function MagnifierOverlay({
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
return (
<MagnifierCrosshair
cursorSvgX={cursorSvgX}
cursorSvgY={cursorSvgY}
viewBoxWidth={viewBoxWidth}
rotationAngle={rotationAngle}
heatStyle={crosshairHeatStyle}
/>
<>
<MagnifierCrosshair
cursorSvgX={cursorSvgX}
cursorSvgY={cursorSvgY}
viewBoxWidth={viewBoxWidth}
rotationAngle={rotationAngle}
heatStyle={crosshairHeatStyle}
/>
{/* Scale probe circles for empirical 1:1 tracking measurement */}
{/* These are invisible but their screen positions are measured via getBoundingClientRect */}
<circle
ref={scaleProbe1Ref}
cx={cursorSvgX - SCALE_PROBE_DISTANCE / 2}
cy={cursorSvgY}
r={0.5}
fill="transparent"
stroke="none"
pointerEvents="none"
data-scale-probe="1"
/>
<circle
ref={scaleProbe2Ref}
cx={cursorSvgX + SCALE_PROBE_DISTANCE / 2}
cy={cursorSvgY}
r={0.5}
fill="transparent"
stroke="none"
pointerEvents="none"
data-scale-probe="2"
/>
</>
)
})()}

View File

@ -15,8 +15,8 @@ import type { SpringValue } from '@react-spring/web'
import { MagnifierOverlay } from './MagnifierOverlay'
import {
useMagnifierTouchHandlers,
type UseMagnifierTouchHandlersOptions,
useMagnifierTouchHandlers,
} from './useMagnifierTouchHandlers'
// ============================================================================

View File

@ -38,6 +38,11 @@
// State Management Hooks
// ============================================================================
export type {
EmpiricalScaleResult,
UseEmpiricalScaleReturn,
} from './useEmpiricalScale'
export { useEmpiricalScale } from './useEmpiricalScale'
export type {
UseMagnifierStateOptions,
UseMagnifierStateReturn,

View File

@ -0,0 +1,142 @@
/**
* Empirical Scale Measurement for 1:1 Touch Tracking
*
* Instead of calculating the scale through all the transform layers,
* this hook empirically measures the actual pixel-to-SVG ratio by
* placing probe elements and comparing their screen positions.
*
* This approach is robust to any changes in the rendering pipeline
* because it measures what's actually happening on screen.
*/
'use client'
import { useCallback, useRef } from 'react'
// ============================================================================
// Types
// ============================================================================
export interface EmpiricalScaleResult {
/** Pixels per SVG unit (measured empirically) */
pixelsPerSvgUnit: number
/** Whether measurement was successful */
isValid: boolean
/** Debug info about the measurement */
debug?: {
probe1Screen: { x: number; y: number }
probe2Screen: { x: number; y: number }
pixelDistance: number
svgDistance: number
}
}
export interface UseEmpiricalScaleReturn {
/** Ref for first probe element (circle at known SVG coords) */
probe1Ref: React.RefObject<SVGCircleElement | null>
/** Ref for second probe element (circle at known SVG coords) */
probe2Ref: React.RefObject<SVGCircleElement | null>
/** Measure the current scale empirically */
measureScale: () => EmpiricalScaleResult
/** The fixed SVG distance between probes (for reference) */
probeSvgDistance: number
}
// ============================================================================
// Constants
// ============================================================================
/**
* Fixed distance between probes in SVG units.
* This should be large enough to get accurate measurements
* but small enough to fit within the magnifier view at all zoom levels.
*/
const PROBE_SVG_DISTANCE = 100
// ============================================================================
// Hook Implementation
// ============================================================================
/**
* Hook for empirically measuring the pixel-to-SVG scale in the magnifier.
*
* Usage:
* 1. Render two probe circles inside the magnifier SVG using probe1Ref and probe2Ref
* 2. Position them at (cursorSvgX - 50, cursorSvgY) and (cursorSvgX + 50, cursorSvgY)
* 3. Call measureScale() during touch move to get the actual pixels-per-SVG-unit
* 4. Use: touchMultiplier = 1 / pixelsPerSvgUnit
*
* @example
* ```tsx
* const { probe1Ref, probe2Ref, measureScale, probeSvgDistance } = useEmpiricalScale()
*
* // In magnifier SVG, at cursor position:
* <circle ref={probe1Ref} cx={cursorSvgX - probeSvgDistance/2} cy={cursorSvgY} r={0.1} opacity={0} />
* <circle ref={probe2Ref} cx={cursorSvgX + probeSvgDistance/2} cy={cursorSvgY} r={0.1} opacity={0} />
*
* // In touch handler:
* const { pixelsPerSvgUnit, isValid } = measureScale()
* if (isValid) {
* const touchMultiplier = 1 / pixelsPerSvgUnit
* // Apply to cursor movement...
* }
* ```
*/
export function useEmpiricalScale(): UseEmpiricalScaleReturn {
const probe1Ref = useRef<SVGCircleElement | null>(null)
const probe2Ref = useRef<SVGCircleElement | null>(null)
const measureScale = useCallback((): EmpiricalScaleResult => {
const probe1 = probe1Ref.current
const probe2 = probe2Ref.current
if (!probe1 || !probe2) {
return { pixelsPerSvgUnit: 1, isValid: false }
}
// Get screen positions of the probes
const rect1 = probe1.getBoundingClientRect()
const rect2 = probe2.getBoundingClientRect()
// Use center of each probe
const screen1 = {
x: rect1.left + rect1.width / 2,
y: rect1.top + rect1.height / 2,
}
const screen2 = {
x: rect2.left + rect2.width / 2,
y: rect2.top + rect2.height / 2,
}
// Calculate pixel distance between probes
const dx = screen2.x - screen1.x
const dy = screen2.y - screen1.y
const pixelDistance = Math.sqrt(dx * dx + dy * dy)
// Guard against zero/invalid measurements
if (pixelDistance < 1 || !Number.isFinite(pixelDistance)) {
return { pixelsPerSvgUnit: 1, isValid: false }
}
// Calculate pixels per SVG unit
const pixelsPerSvgUnit = pixelDistance / PROBE_SVG_DISTANCE
return {
pixelsPerSvgUnit,
isValid: true,
debug: {
probe1Screen: screen1,
probe2Screen: screen2,
pixelDistance,
svgDistance: PROBE_SVG_DISTANCE,
},
}
}, [])
return {
probe1Ref,
probe2Ref,
measureScale,
probeSvgDistance: PROBE_SVG_DISTANCE,
}
}

View File

@ -22,7 +22,7 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
// ============================================================================
// Types

View File

@ -128,6 +128,8 @@ export function useMagnifierTouchHandlers(
svgRef,
containerRef,
cursorPositionRef,
scaleProbe1Ref,
scaleProbe2Ref,
isMagnifierExpanded,
setIsMagnifierExpanded,
getCurrentZoom,
@ -137,6 +139,9 @@ export function useMagnifierTouchHandlers(
parsedViewBox,
} = useMagnifierContext()
// Fixed distance between scale probes in SVG units (must match MagnifierOverlay)
const SCALE_PROBE_SVG_DISTANCE = 100
const {
mapData,
currentPrompt,
@ -150,6 +155,47 @@ export function useMagnifierTouchHandlers(
// Get isPinching from state machine
const isPinchingFromMachine = interaction.isPinching
// -------------------------------------------------------------------------
// Empirical Scale Measurement
// -------------------------------------------------------------------------
/**
* Measure the actual pixels-per-SVG-unit by comparing screen positions
* of the two probe circles. This is robust to any transform pipeline changes.
*/
const measureEmpiricalScale = useCallback((): { pixelsPerSvgUnit: number; isValid: boolean } => {
const probe1 = scaleProbe1Ref.current
const probe2 = scaleProbe2Ref.current
if (!probe1 || !probe2) {
return { pixelsPerSvgUnit: 1, isValid: false }
}
// Get screen positions of the probes
const rect1 = probe1.getBoundingClientRect()
const rect2 = probe2.getBoundingClientRect()
// Use center of each probe
const x1 = rect1.left + rect1.width / 2
const y1 = rect1.top + rect1.height / 2
const x2 = rect2.left + rect2.width / 2
const y2 = rect2.top + rect2.height / 2
// Calculate pixel distance between probes
const dx = x2 - x1
const dy = y2 - y1
const pixelDistance = Math.sqrt(dx * dx + dy * dy)
// Guard against zero/invalid measurements
if (pixelDistance < 1 || !Number.isFinite(pixelDistance)) {
return { pixelsPerSvgUnit: 1, isValid: false }
}
// Calculate pixels per SVG unit
const pixelsPerSvgUnit = pixelDistance / SCALE_PROBE_SVG_DISTANCE
return { pixelsPerSvgUnit, isValid: true }
}, [scaleProbe1Ref, scaleProbe2Ref, SCALE_PROBE_SVG_DISTANCE])
// -------------------------------------------------------------------------
// Refs for touch tracking (internal to this hook)
// -------------------------------------------------------------------------
@ -204,15 +250,42 @@ export function useMagnifierTouchHandlers(
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,
})
}
// State machine handles dragging state via TOUCH_MOVE (transitions to magnifierPanning)
// Note: touchAction: 'none' CSS prevents scrolling
}
},
[getTouchDistance, getCurrentZoom, interaction, setIsMagnifierExpanded, magnifierRef]
[
getTouchDistance,
getCurrentZoom,
interaction,
setIsMagnifierExpanded,
magnifierRef,
cursorPositionRef,
isMagnifierExpanded,
]
)
// -------------------------------------------------------------------------
// Ref to throttle logging (don't spam console on every move)
// -------------------------------------------------------------------------
const lastLogTimeRef = useRef(0)
// -------------------------------------------------------------------------
// Touch Move Handler
// -------------------------------------------------------------------------
@ -273,32 +346,94 @@ export function useMagnifierTouchHandlers(
// Update start position for next move (keep in client coords for delta calculation)
magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY }
// Parse viewBox and get magnifier dimensions
const viewBox = parseViewBoxDimensions(displayViewBox)
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, height: magnifierHeight } = getMagnifierDimensions(
leftoverWidth,
leftoverHeight
)
const actualMagnifierWidth = isMagnifierExpanded ? leftoverWidth : magnifierWidth
const actualMagnifierHeight = isMagnifierExpanded ? leftoverHeight : magnifierHeight
const currentZoom = getCurrentZoom()
// =========================================================================
// EMPIRICAL SCALE MEASUREMENT for 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.
// Calculate touch multiplier for 1:1 panning using extracted utility
const { multiplier: touchMultiplier } = calculateTouchMultiplier(
{
viewBoxWidth: viewBox.width,
viewBoxHeight: viewBox.height,
svgWidth: svgRect.width,
svgHeight: svgRect.height,
},
{
width: actualMagnifierWidth,
height: actualMagnifierHeight,
zoom: currentZoom,
}
)
const empiricalScale = measureEmpiricalScale()
const currentZoom = getCurrentZoom()
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
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
} else {
// Fallback to old calculation
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, height: magnifierHeight } = getMagnifierDimensions(
leftoverWidth,
leftoverHeight
)
const actualMagnifierWidth = isMagnifierExpanded ? leftoverWidth : magnifierWidth
const actualMagnifierHeight = isMagnifierExpanded ? leftoverHeight : magnifierHeight
const touchMultiplierResult = calculateTouchMultiplier(
{
viewBoxWidth: viewBox.width,
viewBoxHeight: viewBox.height,
svgWidth: svgRect.width,
svgHeight: svgRect.height,
},
{
width: actualMagnifierWidth,
height: actualMagnifierHeight,
zoom: currentZoom,
}
)
touchMultiplier = touchMultiplierResult.multiplier
}
// 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
const svgOffsetX = svgRect.left - containerRect.left
@ -407,6 +542,7 @@ export function useMagnifierTouchHandlers(
isMagnifierExpanded,
getTouchDistance,
setTargetZoom,
measureEmpiricalScale,
detectRegions,
onCursorUpdate,
gameMode,
@ -427,6 +563,7 @@ export function useMagnifierTouchHandlers(
hotColdEnabledRef,
largestPieceSizesRef,
parsedViewBox,
interaction,
]
)
@ -473,8 +610,10 @@ export function useMagnifierTouchHandlers(
type: 'TOUCH_END',
touchCount: e.touches.length, // Number of fingers still touching
})
console.log('[handleMagnifierTouchEnd] After dispatch, new phase:',
interaction.state.mode === 'mobile' ? interaction.state.phase : 'N/A')
console.log(
'[handleMagnifierTouchEnd] After dispatch, new phase:',
interaction.state.mode === 'mobile' ? interaction.state.phase : 'N/A'
)
// State machine is authoritative for dragging state (magnifierPanning phase)
magnifierTouchStartRef.current = null