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:
parent
7025439098
commit
639e662d76
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -222,20 +222,9 @@ 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 {
|
||||
console.log('[useMagnifierZoom] 🎬 Starting/updating animation to:', targetZoom.toFixed(1))
|
||||
magnifierApi.start({ zoom: targetZoom })
|
||||
}
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
const inverseMatrix = screenCTM.inverse()
|
||||
// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
const cursorInRegion = regionPath.isPointInFill(svgPoint)
|
||||
|
||||
// If cursor is inside region, track it as region under cursor
|
||||
if (cursorInRegion) {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue