fix: improve magnifier zoom smoothness and debug panel

- Remove immediate snap threshold in useMagnifierZoom - always animate smoothly with react-spring even for large zoom changes (e.g., 14.8× → 313.8× when approaching Vatican City)
- Add smooth cubic falloff for region importance (1 - (d/50)³) to prevent sudden jumps when regions enter detection box
- Reduce size boost from 2.0× to 1.5× and make conditional (only applies when distanceWeight > 0.1) to prevent tiny edge regions from dominating
- Expand detection radius from 75px to 100px for smoother transitions
- Add minimum zoom constraint to ensure magnifier viewport never exceeds detection box size (minZoom >= svgRect.height / 50)
- Compress debug panel Region Analysis: show top 3 regions instead of 5, single-line format
- Fix React duplicate key warning by deduplicating regionDecisions before rendering and using namespaced keys
- Revert to bounding box detection for performance (geometry-based detection was too expensive)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-25 09:48:00 -06:00
parent 7025439098
commit 639e662d76
4 changed files with 90 additions and 197 deletions

View File

@ -2247,65 +2247,33 @@ export function MapRenderer({
</div>
<div style={{ marginTop: '8px' }}>
<strong>Region Analysis:</strong>
<strong>Region Analysis (top 3):</strong>
</div>
{zoomSearchDebugInfo.regionDecisions
{Array.from(
new Map(
zoomSearchDebugInfo.regionDecisions.map((d) => [d.regionId, d])
).values()
)
.sort((a, b) => b.importance - a.importance)
.slice(0, 5)
.slice(0, 3)
.map((decision) => {
const bgColor = decision.wasAccepted
? 'rgba(0, 255, 0, 0.15)'
: 'rgba(128, 128, 128, 0.1)'
const textColor = decision.wasAccepted ? '#0f0' : '#ccc'
const marker = decision.wasAccepted ? '✓' : '✗'
const color = decision.wasAccepted ? '#0f0' : '#888'
return (
<div
key={decision.regionId}
key={`decision-${decision.regionId}`}
style={{
fontSize: '9px',
marginLeft: '8px',
marginTop: '4px',
padding: '4px',
backgroundColor: bgColor,
borderLeft: `2px solid ${textColor}`,
paddingLeft: '6px',
color,
}}
>
<div style={{ fontWeight: 'bold', color: textColor }}>
{decision.regionId} (importance: {decision.importance.toFixed(2)})
</div>
<div>
Size: {decision.currentSize.width.toFixed(1)}×
{decision.currentSize.height.toFixed(1)}px
</div>
{decision.testedZoom && (
<>
<div>
@ {decision.testedZoom.toFixed(1)}×: {decision.magnifiedSize?.width.toFixed(0)}×
{decision.magnifiedSize?.height.toFixed(0)}px
</div>
<div>
Ratio: {((decision.sizeRatio?.width ?? 0) * 100).toFixed(1)}% ×{' '}
{((decision.sizeRatio?.height ?? 0) * 100).toFixed(1)}%
</div>
</>
)}
{decision.rejectionReason && (
<div style={{ color: '#f88', fontStyle: 'italic' }}>
{decision.rejectionReason}
</div>
)}
{decision.wasAccepted && (
<div style={{ color: '#0f0', fontWeight: 'bold' }}> ACCEPTED</div>
)}
{marker} {decision.regionId}: {decision.currentSize.width.toFixed(0)}×
{decision.currentSize.height.toFixed(0)}px
{decision.rejectionReason && ` (${decision.rejectionReason})`}
</div>
)
})}
{zoomSearchDebugInfo.regionDecisions.length > 5 && (
<div style={{ fontSize: '9px', marginLeft: '8px', color: '#888', marginTop: '4px' }}>
...and {zoomSearchDebugInfo.regionDecisions.length - 5} more regions
</div>
)}
</>
)}

View File

@ -222,21 +222,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
console.log('[useMagnifierZoom] ▶️ Resuming - target zoom is below threshold')
}
// 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 {
// Always animate smoothly - react-spring will handle the transition
console.log('[useMagnifierZoom] 🎬 Starting/updating animation to:', targetZoom.toFixed(1))
magnifierApi.start({ zoom: targetZoom })
}
}
}, [
targetZoom, // Effect runs when target zoom changes
pointerLocked, // Effect runs when pointer lock state changes

View File

@ -1,42 +1,18 @@
/**
* Region Detection Hook
*
* Detects which regions are near the cursor using actual SVG path geometry.
* Uses isPointInFill() to test if sample points within the detection box
* intersect with the actual region shapes (not just bounding boxes).
* Detects which regions are near the cursor using bounding box overlap.
* Uses isPointInFill() to determine which region is directly under the cursor.
*
* Returns information about detected regions including:
* - Which regions overlap with the detection box (using actual geometry)
* - Which region is directly under the cursor (using actual geometry)
* - Which regions overlap with the detection box (using bounding box)
* - Which region is directly under the cursor (using isPointInFill)
* - Size information for adaptive cursor dampening
* - Whether there are small regions requiring magnifier zoom
*
* CRITICAL: All detection uses SVG path geometry via isPointInFill(), not
* bounding boxes. This prevents false positives from irregularly shaped regions.
*/
import { useState, useCallback, useRef, useEffect, type RefObject } from 'react'
import type { MapData } from '../types'
import { Polygon, Box, point as Point } from '@flatten-js/core'
/**
* Sample points along an SVG path element to create a Polygon for geometric operations.
* Uses SVG's native getPointAtLength() to sample the actual path geometry.
*/
function pathToPolygon(pathElement: SVGGeometryElement, samplesCount = 50): Polygon {
const pathLength = pathElement.getTotalLength()
const points: Array<[number, number]> = []
// Sample points evenly along the path
for (let i = 0; i <= samplesCount; i++) {
const distance = (pathLength * i) / samplesCount
const pt = pathElement.getPointAtLength(distance)
points.push([pt.x, pt.y])
}
// Create polygon from points
return new Polygon(points.map(([x, y]) => Point(x, y)))
}
export interface DetectionBox {
left: number
@ -120,13 +96,22 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
// Cache polygons to avoid expensive recomputation on every mouse move
const polygonCache = useRef<Map<string, Polygon>>(new Map())
// Cache path elements to avoid repeated querySelector calls
const pathElementCache = useRef<Map<string, SVGGeometryElement>>(new Map())
// Clear cache when map data changes
// Populate path element cache when SVG is available
useEffect(() => {
polygonCache.current.clear()
}, [mapData])
const svgElement = svgRef.current
if (!svgElement) return
pathElementCache.current.clear()
for (const region of mapData.regions) {
const path = svgElement.querySelector(`path[data-region-id="${region.id}"]`)
if (path && path instanceof SVGGeometryElement) {
pathElementCache.current.set(region.id, path)
}
}
}, [svgRef, mapData])
/**
* Detect regions at the given cursor position.
@ -205,24 +190,21 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
const dy = screenCenter.y - cursorClientY
const distanceSquared = dx * dx + dy * dy
// Skip regions whose centers are far from the detection box
// Use generous threshold to avoid false negatives (detection box is 50px, so check 150px)
const MAX_DISTANCE = 150
// Skip regions whose centers are very far from the detection box
// Detection box is 50px, but we check 100px radius to provide smooth transitions
// Regions at 50-100px will have very low importance (smooth cubic falloff)
const MAX_DISTANCE = 100
if (distanceSquared > MAX_DISTANCE * MAX_DISTANCE) {
return // Region is definitely too far away
}
// Region is close enough - now do proper DOM-based checks
const regionPath = svgElement.querySelector(`path[data-region-id="${region.id}"]`)
if (!regionPath || !(regionPath instanceof SVGGeometryElement)) return
// Get cached path element (populated in useEffect)
const regionPath = pathElementCache.current.get(region.id)
if (!regionPath) return
const pathRect = regionPath.getBoundingClientRect()
// CRITICAL: Use actual SVG path geometry, not bounding box
// Sample multiple points within the detection box to check for intersection
// This prevents false positives from irregularly shaped regions
// Second check: does bounding box overlap? (fast rejection)
// Check if bounding box overlaps with detection box
const regionLeft = pathRect.left
const regionRight = pathRect.right
const regionTop = pathRect.top
@ -239,92 +221,19 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
return
}
// Bounding box overlaps - now check actual path geometry using flatten-js
let overlaps = false
let cursorInRegion = false
// SIMPLE AND FAST: Use bounding box overlap for detection
// If bounding box overlaps, the region is detected
const overlaps = boundingBoxOverlaps
// Get the transformation matrix to convert screen coordinates to SVG coordinates
const screenCTM = svgElement.getScreenCTM()
if (!screenCTM) {
// Fallback to bounding box if we can't get coordinate transform
overlaps = true
cursorInRegion =
cursorClientX >= regionLeft &&
cursorClientX <= regionRight &&
cursorClientY >= regionTop &&
cursorClientY <= regionBottom
} else {
// Use the screenCTM we already got at the top (guaranteed non-null)
const inverseMatrix = screenCTM.inverse()
// Check if cursor point is inside the actual region path
// Check if cursor point is inside the actual region path (for "region under cursor")
let svgPoint = svgElement.createSVGPoint()
svgPoint.x = cursorClientX
svgPoint.y = cursorClientY
svgPoint = svgPoint.matrixTransform(inverseMatrix)
cursorInRegion = regionPath.isPointInFill(svgPoint)
// For overlap detection, use flatten-js for precise geometric intersection
try {
// Get or create cached polygon for this region
let regionPolygon = polygonCache.current.get(region.id)
if (!regionPolygon) {
regionPolygon = pathToPolygon(regionPath)
polygonCache.current.set(region.id, regionPolygon)
}
// Convert detection box to SVG coordinates
const boxTopLeft = svgElement.createSVGPoint()
boxTopLeft.x = boxLeft
boxTopLeft.y = boxTop
const boxBottomRight = svgElement.createSVGPoint()
boxBottomRight.x = boxRight
boxBottomRight.y = boxBottom
const svgTopLeft = boxTopLeft.matrixTransform(inverseMatrix)
const svgBottomRight = boxBottomRight.matrixTransform(inverseMatrix)
// Create detection box in SVG coordinates
const detectionBox = new Box(
Math.min(svgTopLeft.x, svgBottomRight.x),
Math.min(svgTopLeft.y, svgBottomRight.y),
Math.max(svgTopLeft.x, svgBottomRight.x),
Math.max(svgTopLeft.y, svgBottomRight.y)
)
// Check for intersection or containment
const intersects = regionPolygon.intersect(detectionBox).length > 0
const boxContainsRegion = detectionBox.contains(regionPolygon.box)
overlaps = intersects || boxContainsRegion
} catch (error) {
// If flatten-js fails (e.g., complex path), fall back to point sampling
console.warn('flatten-js intersection failed, using fallback:', error)
// Fallback: check 9 strategic points
const testPoints = [
{ x: boxLeft, y: boxTop },
{ x: boxRight, y: boxTop },
{ x: boxLeft, y: boxBottom },
{ x: boxRight, y: boxBottom },
{ x: cursorClientX, y: cursorClientY },
{ x: (boxLeft + boxRight) / 2, y: boxTop },
{ x: (boxLeft + boxRight) / 2, y: boxBottom },
{ x: boxLeft, y: (boxTop + boxBottom) / 2 },
{ x: boxRight, y: (boxTop + boxBottom) / 2 },
]
for (const point of testPoints) {
const pt = svgElement.createSVGPoint()
pt.x = point.x
pt.y = point.y
const svgPt = pt.matrixTransform(inverseMatrix)
if (regionPath.isPointInFill(svgPt)) {
overlaps = true
break
}
}
}
}
const cursorInRegion = regionPath.isPointInFill(svgPoint)
// If cursor is inside region, track it as region under cursor
if (cursorInRegion) {

View File

@ -209,17 +209,27 @@ function calculateRegionImportance(
(cursorClientX - regionCenterX) ** 2 + (cursorClientY - regionCenterY) ** 2
)
// Normalize distance to 0-1 range (0 = at cursor, 1 = 50px away or more)
// Use 50px as reference since that's our detection box size
// SMOOTH EDGE FALLOFF: Create a continuous importance curve
// - At cursor (0px): importance = 1.0 (maximum)
// - At 25px: importance ≈ 0.5 (half)
// - At 50px (edge): importance ≈ 0.0 (minimal, but not zero for continuity)
//
// Use smooth cubic falloff: (1 - (d/50)^3)
// This provides:
// - Slow decrease near cursor (plateau at center)
// - Faster decrease at mid-range
// - Very gradual approach to zero at edge (no discontinuity)
const normalizedDistance = Math.min(distanceToCursor / 50, 1)
const distanceWeight = 1 - normalizedDistance // Invert: closer = higher weight
const distanceWeight = Math.max(0, 1 - Math.pow(normalizedDistance, 3))
// 2. Size factor: Smaller regions get boosted importance
// This ensures San Marino can be targeted even when Italy is closer to cursor
const sizeWeight = region.isVerySmall ? 2.0 : 1.0
// HOWEVER: Only apply boost if region has meaningful distance weight (> 0.1)
// This prevents tiny regions at the edge from suddenly dominating
const sizeBoost = region.isVerySmall && distanceWeight > 0.1 ? 1.5 : 1.0
// Combined importance score
return distanceWeight * sizeWeight
return distanceWeight * sizeBoost
}
/**
@ -280,6 +290,23 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Convert cursor position to SVG coordinates
const scaleX = viewBoxWidth / svgRect.width
const scaleY = viewBoxHeight / svgRect.height
// CRITICAL: Calculate minimum zoom to ensure magnified viewport doesn't exceed detection box
// Detection box is 50px, so magnified area should not show more than 50px of screen space
// At zoom Z, the magnifier shows svgRect.height/Z pixels vertically
// We need: svgRect.height/minZoom <= 50
// Therefore: minZoom >= svgRect.height/50
const calculatedMinZoom = Math.max(svgRect.height / 50, minZoom)
if (pointerLocked) {
console.log('[Zoom Search] Min zoom constraint:', {
svgHeight: svgRect.height,
detectionBox: 50,
calculatedMinZoom,
providedMinZoom: minZoom,
finalMinZoom: calculatedMinZoom,
})
}
const cursorSvgX = (cursorX - (svgRect.left - containerRect.left)) * scaleX + viewBoxX
const cursorSvgY = (cursorY - (svgRect.top - containerRect.top)) * scaleY + viewBoxY
@ -363,7 +390,7 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
let optimalZoom = maxZoom
let foundGoodZoom = false
for (let testZoom = maxZoom; testZoom >= minZoom; testZoom *= zoomStep) {
for (let testZoom = maxZoom; testZoom >= calculatedMinZoom; testZoom *= zoomStep) {
// Calculate the SVG viewport that will be shown in the magnifier at this zoom
const magnifiedViewBoxWidth = viewBoxWidth / testZoom
const magnifiedViewBoxHeight = viewBoxHeight / testZoom
@ -509,10 +536,10 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
}
if (!foundGoodZoom) {
// Didn't find a good zoom - use minimum
optimalZoom = minZoom
// Didn't find a good zoom - use calculated minimum
optimalZoom = calculatedMinZoom
if (pointerLocked) {
console.log(`[Zoom Search] ⚠️ No good zoom found, using minimum: ${minZoom}x`)
console.log(`[Zoom Search] ⚠️ No good zoom found, using calculated minimum: ${calculatedMinZoom}x`)
}
}