fix: improve magnifier zoom calculation for multi-piece regions

Problem:
- Portugal's bounding box includes distant Atlantic islands, causing mainland to be ignored
- Algorithm was selecting "largest piece by area" which picked island groups instead of mainland
- This caused Portugal to dominate zoom calculations and prevent Gibraltar from being prioritized

Solution:
- Changed pre-computation to use FIRST piece instead of largest (mainland is typically piece 1)
- Added showDebugBoundingBoxes prop to hide debug rectangles in production
- Improved zoom animation smoothness with gentler spring easing (tension: 120, friction: 30)

Technical details:
- Multi-piece SVG paths split by `z m` separator
- First piece is mainland, subsequent pieces are islands/territories
- Pre-computed sizes cached in useEffect for performance
- Only Portugal logs to console for debugging

🤖 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-19 18:06:03 -06:00
parent 1a54f09814
commit cb4114f344
1 changed files with 385 additions and 162 deletions

View File

@ -57,6 +57,8 @@ interface MapRendererProps {
useObstacles?: boolean
obstaclePadding?: number
}
// Debug flags
showDebugBoundingBoxes?: boolean
}
/**
@ -109,6 +111,7 @@ export function MapRenderer({
guessHistory,
playerMetadata,
forceTuning = {},
showDebugBoundingBoxes = false,
}: MapRendererProps) {
// Extract force tuning parameters with defaults
const {
@ -182,6 +185,15 @@ export function MapRenderer({
const cursorPositionRef = useRef<{ x: number; y: number } | null>(null)
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
// Debug: Track bounding boxes for visualization
const [debugBoundingBoxes, setDebugBoundingBoxes] = useState<
Array<{ regionId: string; x: number; y: number; width: number; height: number }>
>([])
// Pre-computed largest piece sizes for multi-piece regions
// Maps regionId -> {width, height} of the largest piece
const largestPieceSizesRef = useRef<Map<string, { width: number; height: number }>>(new Map())
// Configuration
const MAX_ZOOM = 1000 // Maximum zoom level (for Gibraltar at 0.08px!)
const HIGH_ZOOM_THRESHOLD = 100 // Show gold border above this zoom level
@ -244,6 +256,48 @@ export function MapRenderer({
}
}, [])
// Pre-compute largest piece sizes for multi-piece regions
useEffect(() => {
if (!svgRef.current) return
const largestPieceSizes = new Map<string, { width: number; height: number }>()
mapData.regions.forEach((region) => {
const pathData = region.path
const pieceSeparatorRegex = /(?<=z)\s*m\s*/i
const rawPieces = pathData.split(pieceSeparatorRegex)
if (rawPieces.length > 1) {
// Multi-piece region: use the FIRST piece (mainland), not largest
// The first piece is typically the mainland, with islands as subsequent pieces
const svg = svgRef.current
if (!svg) return
// Just measure the first piece
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
tempPath.setAttribute('d', rawPieces[0]) // First piece already has 'm' command
tempPath.style.visibility = 'hidden'
svg.appendChild(tempPath)
const bbox = tempPath.getBoundingClientRect()
const firstPieceSize = { width: bbox.width, height: bbox.height }
svg.removeChild(tempPath)
largestPieceSizes.set(region.id, firstPieceSize)
// Only log Portugal for debugging
if (region.id === 'pt') {
console.log(
`[Pre-compute] ${region.id}: Using first piece (mainland): ${firstPieceSize.width.toFixed(2)}px × ${firstPieceSize.height.toFixed(2)}px`
)
}
}
})
largestPieceSizesRef.current = largestPieceSizes
}, [mapData])
// Request pointer lock on first click
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
console.log('[MapRenderer] Container clicked:', {
@ -268,7 +322,8 @@ export function MapRenderer({
// Animated spring values for smooth transitions
// Different fade speeds: fast fade-in (100ms), slow fade-out (1000ms)
// Position animates with medium speed (300ms)
// Zoom: smooth, slower animation with gentle easing
// Position: medium speed (300ms)
const magnifierSpring = useSpring({
zoom: targetZoom,
opacity: targetOpacity,
@ -280,16 +335,14 @@ export function MapRenderer({
? { duration: 100 } // Fade in: 0.1 seconds
: { duration: 1000 } // Fade out: 1 second
}
// Position and zoom: medium speed
if (key === 'zoom') {
// Zoom: smooth, slower animation with gentle easing
return { tension: 120, friction: 30, mass: 1 }
}
// Position: medium speed
return { tension: 200, friction: 25 }
},
onChange: (result) => {
console.log('[Magnifier Spring] Animating:', {
opacity: result.value.opacity?.toFixed(2),
top: result.value.top?.toFixed(0),
left: result.value.left?.toFixed(0),
})
},
// onChange removed - was flooding console with animation frames
})
const [labelPositions, setLabelPositions] = useState<RegionLabelPosition[]>([])
@ -820,11 +873,6 @@ export function MapRenderer({
if (isVerySmall) {
hasSmallRegion = true
console.log('[Magnifier] Small region detected:', region.id, {
width: pixelWidth,
height: pixelHeight,
area: pixelArea,
})
}
// Track smallest region size for cursor dampening (use smallest in detection box)
@ -834,12 +882,39 @@ export function MapRenderer({
}
})
// Sort detected regions by size (smallest first) to prioritize tiny regions in zoom calculation
// This ensures Gibraltar (0.08px) is checked before Spain (81px) when finding optimal zoom
detectedRegions.sort((a, b) => {
const pathA = svgRef.current?.querySelector(`path[data-region-id="${a}"]`)
const pathB = svgRef.current?.querySelector(`path[data-region-id="${b}"]`)
if (!pathA || !pathB) return 0
const rectA = pathA.getBoundingClientRect()
const rectB = pathB.getBoundingClientRect()
// Use smallest dimension (width or height) for comparison
const sizeA = Math.min(rectA.width, rectA.height)
const sizeB = Math.min(rectB.width, rectB.height)
return sizeA - sizeB // Smallest first
})
if (pointerLocked && detectedRegions.length > 0) {
const sortedSizes = detectedRegions.map((id) => {
const path = svgRef.current?.querySelector(`path[data-region-id="${id}"]`)
if (!path) return `${id}: ?`
const rect = path.getBoundingClientRect()
const size = Math.min(rect.width, rect.height)
return `${id}: ${size.toFixed(2)}px`
})
console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes)
}
// Calculate adaptive zoom level based on region density and size
// Base zoom: 8x
// More regions = more zoom (up to +8x for 10+ regions)
// Smaller regions = more zoom (up to +8x for very tiny regions)
// Show magnifier if: 7+ regions in detection box OR any region smaller than 15px
const shouldShow = regionsInBox >= 7 || hasSmallRegion
// Show magnifier only when there are small regions (< 15px)
const shouldShow = hasSmallRegion
// Update smallest region size for adaptive cursor dampening
if (shouldShow && detectedSmallestSize !== Infinity) {
@ -854,135 +929,258 @@ export function MapRenderer({
setHoveredRegion(regionUnderCursor)
}
// Debug logging - ONLY for Gibraltar or ultra-small regions
const hasGibraltar = detectedRegions.includes('gi')
if (hasGibraltar || detectedSmallestSize < 1) {
console.log(
`[Magnifier] ${hasGibraltar ? '🎯 GIBRALTAR DETECTED' : '🔍 Tiny region'} Detection:`,
{
detectedRegionIds: detectedRegions,
regionsInBox,
smallestScreenSize: detectedSmallestSize.toFixed(4) + 'px',
shouldShow,
movementMultiplier: getMovementMultiplier(detectedSmallestSize).toFixed(2),
}
)
}
// Magnifier detection logging removed for performance
if (shouldShow) {
// Binary search for optimal zoom level
// Goal: Find zoom where regions fit nicely in magnifier (taking 10-20% of area)
const TARGET_AREA_MIN = 0.10 // 10% of magnifier
const TARGET_AREA_MAX = 0.20 // 20% of magnifier
// Adaptive threshold based on smallest detected region
// For ultra-small regions (< 1px), we need a lower acceptance threshold
// Otherwise Gibraltar (0.08px) will never fit the 10-25% range even at 1000x zoom
let minAcceptableRatio = 0.1 // Default: 10% minimum
let maxAcceptableRatio = 0.25 // Default: 25% maximum
// Get magnifier dimensions
const magnifierWidth = containerRect.width * 0.5
const magnifierHeight = magnifierWidth / 2
const magnifierArea = magnifierWidth * magnifierHeight
if (detectedSmallestSize < 1) {
// Sub-pixel regions: accept 2-8% of magnifier
minAcceptableRatio = 0.02
maxAcceptableRatio = 0.08
} else if (detectedSmallestSize < 5) {
// Tiny regions (1-5px): accept 5-15% of magnifier
minAcceptableRatio = 0.05
maxAcceptableRatio = 0.15
}
// Get SVG viewBox for coordinate conversion
if (pointerLocked) {
console.log('[Zoom Search] Adaptive thresholds:', {
detectedSmallestSize: detectedSmallestSize.toFixed(4) + 'px',
minAcceptableRatio: (minAcceptableRatio * 100).toFixed(1) + '%',
maxAcceptableRatio: (maxAcceptableRatio * 100).toFixed(1) + '%',
})
}
// Zoom-out approach: Start from max zoom and reduce until a region fits nicely
// Goal: Find zoom where any region occupies ~15% of magnifier width or height
const TARGET_RATIO = 0.15 // Region should occupy 15% of magnifier dimension
// Get SVG viewBox for bounding box conversion
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2] || 1000
const viewBoxHeight = viewBoxParts[3] || 1000
// Binary search bounds
let minZoom = 1
let maxZoom = MAX_ZOOM
let adaptiveZoom = 10
let iterations = 0
const MAX_ITERATIONS = 20
while (iterations < MAX_ITERATIONS && maxZoom - minZoom > 0.1) {
iterations++
const testZoom = (minZoom + maxZoom) / 2
// Calculate magnified viewBox dimensions at this zoom
const magnifiedViewBoxWidth = viewBoxWidth / testZoom
const magnifiedViewBoxHeight = viewBoxHeight / testZoom
const magnifiedViewBoxArea = magnifiedViewBoxWidth * magnifiedViewBoxHeight
// Check regions in detection box to see how they fit
let anyRegionFullyInside = false
let largestRegionRatio = 0
detectedRegions.forEach((regionId) => {
const region = mapData.regions.find((r) => r.id === regionId)
if (!region) return
const regionPath = svgRef.current?.querySelector(`path[data-region-id="${regionId}"]`)
if (!regionPath) return
const pathRect = regionPath.getBoundingClientRect()
const regionPixelArea = pathRect.width * pathRect.height
// Convert pixel area to viewBox area (approximate)
const scaleX = viewBoxWidth / svgRect.width
const scaleY = viewBoxHeight / svgRect.height
const regionViewBoxArea = regionPixelArea * scaleX * scaleY
// Check if region fits in magnified view
const regionRatioInMagnifier = regionViewBoxArea / magnifiedViewBoxArea
if (regionRatioInMagnifier < 1.0) {
anyRegionFullyInside = true
largestRegionRatio = Math.max(largestRegionRatio, regionRatioInMagnifier)
}
})
// Binary search logic
if (!anyRegionFullyInside) {
// No regions fit - zoom out
maxZoom = testZoom
} else if (largestRegionRatio < TARGET_AREA_MIN) {
// Regions too small - zoom in
minZoom = testZoom
} else if (largestRegionRatio > TARGET_AREA_MAX) {
// Regions too large - zoom out
maxZoom = testZoom
} else {
// Just right!
adaptiveZoom = testZoom
break
}
adaptiveZoom = testZoom
}
// Debug logging for Gibraltar
const hasGibraltar = detectedRegions.includes('gi')
if (hasGibraltar) {
console.log(`[Zoom] 🎯 BINARY SEARCH RESULT:`, {
iterations,
finalZoom: adaptiveZoom.toFixed(1),
detectedRegions,
})
}
// Calculate magnifier position (opposite corner from cursor)
// containerRect already declared at top of function
// Magnifier dimensions
const magnifierWidth = containerRect.width * 0.5
const magnifierHeight = magnifierWidth / 2
// Calculate target sizes: region should be this big in magnifier
const targetWidthPx = magnifierWidth * TARGET_RATIO
const targetHeightPx = magnifierHeight * TARGET_RATIO
// Track bounding boxes for debug visualization
const boundingBoxes: Array<{
regionId: string
x: number
y: number
width: number
height: number
}> = []
// Start from max zoom and work down until we find a good fit
let adaptiveZoom = MAX_ZOOM
let foundGoodZoom = false
// We'll test zoom levels by halving each time to find a good range quickly
const MIN_ZOOM = 1
const ZOOM_STEP = 0.9 // Reduce by 10% each iteration
// Convert cursor position to SVG coordinates
const scaleX = viewBoxWidth / svgRect.width
const scaleY = viewBoxHeight / svgRect.height
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const cursorSvgX = (cursorX - (svgRect.left - containerRect.left)) * scaleX + viewBoxX
const cursorSvgY = (cursorY - (svgRect.top - containerRect.top)) * scaleY + viewBoxY
// Zoom search logging disabled for performance
for (let testZoom = MAX_ZOOM; testZoom >= MIN_ZOOM; testZoom *= ZOOM_STEP) {
// Calculate the SVG viewport that will be shown in the magnifier at this zoom
const magnifiedViewBoxWidth = viewBoxWidth / testZoom
const magnifiedViewBoxHeight = viewBoxHeight / testZoom
// The viewport is centered on cursor position, but clamped to map bounds
let viewportLeft = cursorSvgX - magnifiedViewBoxWidth / 2
let viewportRight = cursorSvgX + magnifiedViewBoxWidth / 2
let viewportTop = cursorSvgY - magnifiedViewBoxHeight / 2
let viewportBottom = cursorSvgY + magnifiedViewBoxHeight / 2
// Clamp viewport to stay within map bounds
const mapLeft = viewBoxX
const mapRight = viewBoxX + viewBoxWidth
const mapTop = viewBoxY
const mapBottom = viewBoxY + viewBoxHeight
let wasClamped = false
const originalViewport = {
left: viewportLeft,
right: viewportRight,
top: viewportTop,
bottom: viewportBottom,
}
// If viewport extends beyond left edge, shift it right
if (viewportLeft < mapLeft) {
const shift = mapLeft - viewportLeft
viewportLeft += shift
viewportRight += shift
wasClamped = true
}
// If viewport extends beyond right edge, shift it left
if (viewportRight > mapRight) {
const shift = viewportRight - mapRight
viewportLeft -= shift
viewportRight -= shift
wasClamped = true
}
// If viewport extends beyond top edge, shift it down
if (viewportTop < mapTop) {
const shift = mapTop - viewportTop
viewportTop += shift
viewportBottom += shift
wasClamped = true
}
// If viewport extends beyond bottom edge, shift it up
if (viewportBottom > mapBottom) {
const shift = viewportBottom - mapBottom
viewportTop -= shift
viewportBottom -= shift
wasClamped = true
}
// Viewport logging disabled for performance
// Check all detected regions to see if any are inside this viewport and fit nicely
let foundFit = false
const regionsChecked: Array<{ id: string; inside: boolean; ratio?: number }> = []
for (const regionId of detectedRegions) {
const region = mapData.regions.find((r) => r.id === regionId)
if (!region) continue
const regionPath = svgRef.current?.querySelector(`path[data-region-id="${regionId}"]`)
if (!regionPath) continue
// Use pre-computed largest piece size for multi-piece regions
let currentWidth: number
let currentHeight: number
const cachedSize = largestPieceSizesRef.current.get(regionId)
if (cachedSize) {
// Multi-piece region: use pre-computed largest piece
currentWidth = cachedSize.width
currentHeight = cachedSize.height
} else {
// Single-piece region: use normal bounding box
const pathRect = regionPath.getBoundingClientRect()
currentWidth = pathRect.width
currentHeight = pathRect.height
}
const pathRect = regionPath.getBoundingClientRect()
// Convert region bounding box to SVG coordinates
const regionSvgLeft = (pathRect.left - svgRect.left) * scaleX + viewBoxX
const regionSvgRight = regionSvgLeft + pathRect.width * scaleX
const regionSvgTop = (pathRect.top - svgRect.top) * scaleY + viewBoxY
const regionSvgBottom = regionSvgTop + pathRect.height * scaleY
// Check if region is inside the magnified viewport
const isInsideViewport =
regionSvgLeft < viewportRight &&
regionSvgRight > viewportLeft &&
regionSvgTop < viewportBottom &&
regionSvgBottom > viewportTop
regionsChecked.push({ id: regionId, inside: isInsideViewport })
if (!isInsideViewport) continue // Skip regions not in viewport
// Region is in viewport - check if it's a good size
const magnifiedWidth = currentWidth * testZoom
const magnifiedHeight = currentHeight * testZoom
const widthRatio = magnifiedWidth / magnifierWidth
const heightRatio = magnifiedHeight / magnifierHeight
// Update the checked region data with ratio
regionsChecked[regionsChecked.length - 1].ratio = Math.max(widthRatio, heightRatio)
// If either dimension is within our adaptive acceptance range, we found a good zoom
if (
(widthRatio >= minAcceptableRatio && widthRatio <= maxAcceptableRatio) ||
(heightRatio >= minAcceptableRatio && heightRatio <= maxAcceptableRatio)
) {
adaptiveZoom = testZoom
foundFit = true
foundGoodZoom = true
// Only log when we actually accept a zoom
console.log(
`[Zoom] ✅ Accepted ${testZoom.toFixed(1)}x for ${regionId} (${currentWidth.toFixed(1)}px × ${currentHeight.toFixed(1)}px)`
)
// Save bounding box for this region
boundingBoxes.push({
regionId,
x: regionSvgLeft,
y: regionSvgTop,
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
})
break // Found a good zoom, stop checking regions
}
}
if (foundFit) break // Found a good zoom level, stop searching
}
if (!foundGoodZoom) {
// Didn't find a good zoom - use minimum
adaptiveZoom = MIN_ZOOM
if (pointerLocked) {
console.log(`[Zoom Search] ⚠️ No good zoom found, using minimum: ${MIN_ZOOM}x`)
}
}
// Save bounding boxes for rendering
setDebugBoundingBoxes(boundingBoxes)
// Calculate magnifier position (opposite corner from cursor)
// magnifierWidth and magnifierHeight already declared above
const isLeftHalf = cursorX < containerRect.width / 2
const isTopHalf = cursorY < containerRect.height / 2
const newTop = isTopHalf ? containerRect.height - magnifierHeight - 20 : 20
const newLeft = isLeftHalf ? containerRect.width - magnifierWidth - 20 : 20
if (pointerLocked) {
console.log(
'[Magnifier] SHOWING with zoom:',
adaptiveZoom,
'| Setting opacity to 1, position:',
{ top: newTop, left: newLeft }
)
}
setTargetZoom(adaptiveZoom)
setShowMagnifier(true)
setTargetOpacity(1)
setTargetTop(newTop)
setTargetLeft(newLeft)
} else {
if (pointerLocked) {
console.log('[Magnifier] HIDING - not enough regions or too large | Setting opacity to 0')
}
setShowMagnifier(false)
setTargetOpacity(0)
setDebugBoundingBoxes([]) // Clear bounding boxes when hiding
}
}
@ -997,6 +1195,7 @@ export function MapRenderer({
setShowMagnifier(false)
setTargetOpacity(0)
setCursorPosition(null)
setDebugBoundingBoxes([]) // Clear bounding boxes when leaving
cursorPositionRef.current = null
}
@ -1115,6 +1314,39 @@ export function MapRenderer({
)
})}
{/* Debug: Render bounding boxes (only if enabled) */}
{showDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => (
<g key={`bbox-${bbox.regionId}`}>
<rect
x={bbox.x}
y={bbox.y}
width={bbox.width}
height={bbox.height}
fill="none"
stroke="#ff0000"
strokeWidth={viewBoxWidth / 500}
vectorEffect="non-scaling-stroke"
strokeDasharray="3,3"
pointerEvents="none"
opacity={0.8}
/>
{/* Label showing region ID */}
<text
x={bbox.x + bbox.width / 2}
y={bbox.y + bbox.height / 2}
fill="#ff0000"
fontSize={viewBoxWidth / 80}
textAnchor="middle"
dominantBaseline="middle"
pointerEvents="none"
style={{ fontWeight: 'bold' }}
>
{bbox.regionId}
</text>
</g>
))}
{/* Arrow marker definition */}
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
@ -1354,17 +1586,7 @@ export function MapRenderer({
{/* Custom Cursor - Visible when pointer lock is active */}
{(() => {
console.log('[Custom Cursor] Render check:', {
pointerLocked,
hasCursorPosition: !!cursorPosition,
cursorPosition,
shouldRender: pointerLocked && cursorPosition,
})
if (pointerLocked && cursorPosition) {
console.log('[Custom Cursor] ✅ RENDERING at position:', cursorPosition)
}
// Debug logging removed - was flooding console
return pointerLocked && cursorPosition ? (
<div
data-element="custom-cursor"
@ -1423,7 +1645,8 @@ export function MapRenderer({
width: '50%',
aspectRatio: '2/1',
// High zoom (>60x) gets gold border, normal zoom gets blue border
border: magnifierSpring.zoom.to((zoom) =>
border: magnifierSpring.zoom.to(
(zoom) =>
zoom > HIGH_ZOOM_THRESHOLD
? `4px solid ${isDark ? '#fbbf24' : '#f59e0b'}` // gold-400/gold-500
: `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}` // blue-400/blue-600