feat: add visual debugging for zoom importance scoring

Add comprehensive visual debugging for the adaptive zoom algorithm:
- Render bounding boxes for all detected regions (not just accepted one)
- Color-code by importance: green (accepted), orange (high), yellow (medium), gray (low)
- Display importance scores calculated from distance + size weighting
- Use HTML overlays for text labels (always readable at any zoom level)
- Enable automatically in development mode via SHOW_DEBUG_BOUNDING_BOXES flag

This helps diagnose zoom behavior issues by showing:
- Which region "won" the importance calculation (green box)
- Exact importance scores (distance × size weighting)
- Bounding box rectangles vs actual region shapes

🤖 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-24 18:24:29 -06:00
parent 0aee60d8d1
commit e60a2c09c0
4 changed files with 243 additions and 75 deletions

View File

@ -83,7 +83,5 @@
"ask": [] "ask": []
}, },
"enableAllProjectMcpServers": true, "enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [ "enabledMcpjsonServers": ["sqlite"]
"sqlite"
]
} }

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { useSpring, useSpringRef, animated } from '@react-spring/web' import { useSpring, animated } from '@react-spring/web'
import { css } from '@styled/css' import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import type { MapData, MapRegion } from '../types' import type { MapData, MapRegion } from '../types'
@ -19,7 +19,10 @@ import {
calculateMaxZoomAtThreshold, calculateMaxZoomAtThreshold,
isAboveThreshold, isAboveThreshold,
} from '../utils/screenPixelRatio' } from '../utils/screenPixelRatio'
import { findOptimalZoom } from '../utils/adaptiveZoomSearch' import {
findOptimalZoom,
type BoundingBox as DebugBoundingBox,
} from '../utils/adaptiveZoomSearch'
import { useRegionDetection } from '../hooks/useRegionDetection' import { useRegionDetection } from '../hooks/useRegionDetection'
import { usePointerLock } from '../hooks/usePointerLock' import { usePointerLock } from '../hooks/usePointerLock'
import { useMagnifierZoom } from '../hooks/useMagnifierZoom' import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
@ -27,6 +30,9 @@ import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
// Debug flag: show technical info in magnifier (dev only) // Debug flag: show technical info in magnifier (dev only)
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development' const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
// Debug flag: show bounding boxes with importance scores (dev only)
const SHOW_DEBUG_BOUNDING_BOXES = process.env.NODE_ENV === 'development'
// Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation // Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation
const PRECISION_MODE_THRESHOLD = 20 const PRECISION_MODE_THRESHOLD = 20
@ -125,7 +131,7 @@ export function MapRenderer({
guessHistory, guessHistory,
playerMetadata, playerMetadata,
forceTuning = {}, forceTuning = {},
showDebugBoundingBoxes = false, showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
}: MapRendererProps) { }: MapRendererProps) {
// Extract force tuning parameters with defaults // Extract force tuning parameters with defaults
const { const {
@ -249,9 +255,7 @@ export function MapRenderer({
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity) const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
// Debug: Track bounding boxes for visualization // Debug: Track bounding boxes for visualization
const [debugBoundingBoxes, setDebugBoundingBoxes] = useState< const [debugBoundingBoxes, setDebugBoundingBoxes] = useState<DebugBoundingBox[]>([])
Array<{ regionId: string; x: number; y: number; width: number; height: number }>
>([])
// Pre-computed largest piece sizes for multi-piece regions // Pre-computed largest piece sizes for multi-piece regions
// Maps regionId -> {width, height} of the largest piece // Maps regionId -> {width, height} of the largest piece
@ -362,7 +366,6 @@ export function MapRenderer({
console.log('[CLICK] Using closest detected region:', { console.log('[CLICK] Using closest detected region:', {
regionId: closestRegion.id, regionId: closestRegion.id,
regionName: region.name, regionName: region.name,
distance: closestRegion.distanceToCenter?.toFixed(2),
}) })
onRegionClick(closestRegion.id, region.name) onRegionClick(closestRegion.id, region.name)
} }
@ -1048,10 +1051,7 @@ export function MapRenderer({
totalRegionArea, totalRegionArea,
} = detectionResult } = detectionResult
// Extract region IDs for zoom search (already sorted smallest-first by hook) if (pointerLocked && detectedRegionObjects.length > 0) {
const detectedRegions = detectedRegionObjects.map((r) => r.id)
if (pointerLocked && detectedRegions.length > 0) {
const sortedSizes = detectedRegionObjects.map((r) => `${r.id}: ${r.screenSize.toFixed(2)}px`) const sortedSizes = detectedRegionObjects.map((r) => `${r.id}: ${r.screenSize.toFixed(2)}px`)
console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes) console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes)
} }
@ -1075,7 +1075,7 @@ export function MapRenderer({
if (shouldShow) { if (shouldShow) {
// Use adaptive zoom search utility to find optimal zoom // Use adaptive zoom search utility to find optimal zoom
const zoomSearchResult = findOptimalZoom({ const zoomSearchResult = findOptimalZoom({
detectedRegions, detectedRegions: detectedRegionObjects,
detectedSmallestSize, detectedSmallestSize,
cursorX, cursorX,
cursorY, cursorY,
@ -1272,36 +1272,42 @@ export function MapRenderer({
{/* Debug: Render bounding boxes (only if enabled) */} {/* Debug: Render bounding boxes (only if enabled) */}
{showDebugBoundingBoxes && {showDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => ( debugBoundingBoxes.map((bbox) => {
<g key={`bbox-${bbox.regionId}`}> // Color based on acceptance and importance
<rect // Green = accepted, Orange = high importance, Yellow = medium, Gray = low
x={bbox.x} const importance = bbox.importance ?? 0
y={bbox.y} let strokeColor = '#888888' // Default gray for low importance
width={bbox.width} let fillColor = 'rgba(136, 136, 136, 0.1)'
height={bbox.height}
fill="none" if (bbox.wasAccepted) {
stroke="#ff0000" strokeColor = '#00ff00' // Green for accepted region
strokeWidth={viewBoxWidth / 500} fillColor = 'rgba(0, 255, 0, 0.15)'
vectorEffect="non-scaling-stroke" } else if (importance > 1.5) {
strokeDasharray="3,3" strokeColor = '#ff6600' // Orange for high importance (2.0× boost + close)
pointerEvents="none" fillColor = 'rgba(255, 102, 0, 0.1)'
opacity={0.8} } else if (importance > 0.5) {
/> strokeColor = '#ffcc00' // Yellow for medium importance
{/* Label showing region ID */} fillColor = 'rgba(255, 204, 0, 0.1)'
<text }
x={bbox.x + bbox.width / 2}
y={bbox.y + bbox.height / 2} return (
fill="#ff0000" <g key={`bbox-${bbox.regionId}`}>
fontSize={viewBoxWidth / 80} <rect
textAnchor="middle" x={bbox.x}
dominantBaseline="middle" y={bbox.y}
pointerEvents="none" width={bbox.width}
style={{ fontWeight: 'bold' }} height={bbox.height}
> fill={fillColor}
{bbox.regionId} stroke={strokeColor}
</text> strokeWidth={viewBoxWidth / 500}
</g> vectorEffect="non-scaling-stroke"
))} strokeDasharray="3,3"
pointerEvents="none"
opacity={0.9}
/>
</g>
)
})}
{/* Arrow marker definition */} {/* Arrow marker definition */}
<defs> <defs>
@ -1540,6 +1546,64 @@ export function MapRenderer({
</div> </div>
))} ))}
{/* Debug: Bounding box labels as HTML overlays */}
{showDebugBoundingBoxes &&
containerRef.current &&
svgRef.current &&
debugBoundingBoxes.map((bbox) => {
const importance = bbox.importance ?? 0
let strokeColor = '#888888'
if (bbox.wasAccepted) {
strokeColor = '#00ff00'
} else if (importance > 1.5) {
strokeColor = '#ff6600'
} else if (importance > 0.5) {
strokeColor = '#ffcc00'
}
// Convert SVG coordinates to pixel coordinates
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
const viewBoxParts = mapData.viewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
const viewBoxHeight = viewBoxParts[3] || 1000
const scaleX = svgRect.width / viewBoxWidth
const scaleY = svgRect.height / viewBoxHeight
const svgOffsetX = svgRect.left - containerRect.left
const svgOffsetY = svgRect.top - containerRect.top
// Convert bbox center from SVG coords to pixels
const centerX = (bbox.x + bbox.width / 2 - viewBoxX) * scaleX + svgOffsetX
const centerY = (bbox.y + bbox.height / 2 - viewBoxY) * scaleY + svgOffsetY
return (
<div
key={`bbox-label-${bbox.regionId}`}
style={{
position: 'absolute',
left: `${centerX}px`,
top: `${centerY}px`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 15,
fontSize: '10px',
fontWeight: 'bold',
color: strokeColor,
textAlign: 'center',
textShadow: '0 0 2px black, 0 0 2px black, 0 0 2px black',
whiteSpace: 'nowrap',
}}
>
<div>{bbox.regionId}</div>
<div style={{ fontSize: '8px', fontWeight: 'normal' }}>{importance.toFixed(2)}</div>
</div>
)
})}
{/* Custom Cursor - Visible when pointer lock is active */} {/* Custom Cursor - Visible when pointer lock is active */}
{(() => { {(() => {
// Debug logging removed - was flooding console // Debug logging removed - was flooding console
@ -2034,7 +2098,8 @@ export function MapRenderer({
<div>Regions detected: {detectedRegions.length}</div> <div>Regions detected: {detectedRegions.length}</div>
<div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div> <div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div>
<div> <div>
Smallest size: {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`} Smallest size:{' '}
{detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
</div> </div>
<div style={{ marginTop: '8px' }}> <div style={{ marginTop: '8px' }}>
<strong>Detected Regions:</strong> <strong>Detected Regions:</strong>

View File

@ -281,9 +281,7 @@ describe('adaptiveZoomSearch', () => {
const region1: Bounds = { left: 150, right: 250, top: 150, bottom: 250 } const region1: Bounds = { left: 150, right: 250, top: 150, bottom: 250 }
const region2: Bounds = { left: 100, right: 200, top: 100, bottom: 200 } const region2: Bounds = { left: 100, right: 200, top: 100, bottom: 200 }
expect(isRegionInViewport(region1, region2)).toBe( expect(isRegionInViewport(region1, region2)).toBe(isRegionInViewport(region2, region1))
isRegionInViewport(region2, region1)
)
}) })
}) })
}) })

View File

@ -14,10 +14,11 @@
*/ */
import type { MapData } from '../types' import type { MapData } from '../types'
import type { DetectedRegion } from '../hooks/useRegionDetection'
export interface AdaptiveZoomSearchContext { export interface AdaptiveZoomSearchContext {
/** Detected region IDs, sorted by size (smallest first) */ /** Detected region objects with size and metadata */
detectedRegions: string[] detectedRegions: DetectedRegion[]
/** Size of the smallest detected region in pixels */ /** Size of the smallest detected region in pixels */
detectedSmallestSize: number detectedSmallestSize: number
/** Cursor position in container coordinates */ /** Cursor position in container coordinates */
@ -43,12 +44,21 @@ export interface AdaptiveZoomSearchContext {
pointerLocked?: boolean pointerLocked?: boolean
} }
export interface Bounds {
left: number
right: number
top: number
bottom: number
}
export interface BoundingBox { export interface BoundingBox {
regionId: string regionId: string
x: number x: number
y: number y: number
width: number width: number
height: number height: number
importance?: number
wasAccepted?: boolean
} }
export interface AdaptiveZoomSearchResult { export interface AdaptiveZoomSearchResult {
@ -56,7 +66,7 @@ export interface AdaptiveZoomSearchResult {
zoom: number zoom: number
/** Whether a good zoom was found (false = using minimum zoom as fallback) */ /** Whether a good zoom was found (false = using minimum zoom as fallback) */
foundGoodZoom: boolean foundGoodZoom: boolean
/** Debug bounding boxes for visualization */ /** Debug bounding boxes for visualization (includes all detected regions) */
boundingBoxes: BoundingBox[] boundingBoxes: BoundingBox[]
} }
@ -96,9 +106,9 @@ export function calculateAdaptiveThresholds(smallestSize: number): {
* @returns Clamped viewport bounds * @returns Clamped viewport bounds
*/ */
export function clampViewportToMapBounds( export function clampViewportToMapBounds(
viewport: { left: number; right: number; top: number; bottom: number }, viewport: Bounds,
mapBounds: { left: number; right: number; top: number; bottom: number } mapBounds: Bounds
): { left: number; right: number; top: number; bottom: number; wasClamped: boolean } { ): Bounds & { wasClamped: boolean } {
let { left, right, top, bottom } = viewport let { left, right, top, bottom } = viewport
let wasClamped = false let wasClamped = false
@ -144,10 +154,7 @@ export function clampViewportToMapBounds(
* @param viewport - Viewport bounds in SVG coordinates * @param viewport - Viewport bounds in SVG coordinates
* @returns True if region overlaps viewport * @returns True if region overlaps viewport
*/ */
export function isRegionInViewport( export function isRegionInViewport(regionBounds: Bounds, viewport: Bounds): boolean {
regionBounds: { left: number; right: number; top: number; bottom: number },
viewport: { left: number; right: number; top: number; bottom: number }
): boolean {
return ( return (
regionBounds.left < viewport.right && regionBounds.left < viewport.right &&
regionBounds.right > viewport.left && regionBounds.right > viewport.left &&
@ -156,6 +163,48 @@ export function isRegionInViewport(
) )
} }
/**
* Calculate importance score for a region based on distance from cursor and size.
*
* Smaller regions and regions closer to cursor get higher importance scores.
* This ensures we zoom appropriately for the region the user is actually targeting.
*
* @param region - Detected region with size metadata
* @param cursorX - Cursor X in container coordinates
* @param cursorY - Cursor Y in container coordinates
* @param regionCenterX - Region center X in screen coordinates
* @param regionCenterY - Region center Y in screen coordinates
* @param containerRect - Container bounding rect
* @returns Importance score (higher = more important)
*/
function calculateRegionImportance(
region: DetectedRegion,
cursorX: number,
cursorY: number,
regionCenterX: number,
regionCenterY: number,
containerRect: DOMRect
): number {
// 1. Distance factor: Closer to cursor = more important
const cursorClientX = containerRect.left + cursorX
const cursorClientY = containerRect.top + cursorY
const distanceToCursor = Math.sqrt(
(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
const normalizedDistance = Math.min(distanceToCursor / 50, 1)
const distanceWeight = 1 - normalizedDistance // Invert: closer = higher weight
// 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
// Combined importance score
return distanceWeight * sizeWeight
}
/** /**
* Find optimal zoom level for the detected regions. * Find optimal zoom level for the detected regions.
* *
@ -225,8 +274,69 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
bottom: viewBoxY + viewBoxHeight, bottom: viewBoxY + viewBoxHeight,
} }
// Track bounding boxes for debug visualization // Calculate importance scores for all detected regions
const boundingBoxes: BoundingBox[] = [] // This weights regions by distance from cursor and size
const regionsWithScores = detectedRegions.map((detectedRegion) => {
const regionPath = svgElement.querySelector(`path[data-region-id="${detectedRegion.id}"]`)
if (!regionPath) {
return { region: detectedRegion, importance: 0, centerX: 0, centerY: 0 }
}
const pathRect = regionPath.getBoundingClientRect()
const regionCenterX = pathRect.left + pathRect.width / 2
const regionCenterY = pathRect.top + pathRect.height / 2
const importance = calculateRegionImportance(
detectedRegion,
cursorX,
cursorY,
regionCenterX,
regionCenterY,
containerRect
)
return { region: detectedRegion, importance, centerX: regionCenterX, centerY: regionCenterY }
})
// Sort by importance (highest first)
const sortedRegions = regionsWithScores.sort((a, b) => b.importance - a.importance)
if (pointerLocked) {
console.log(
'[Zoom Search] Region importance scores:',
sortedRegions.map((r) => `${r.region.id}: ${r.importance.toFixed(2)}`)
)
}
// Track bounding boxes for debug visualization - add ALL detected regions upfront
const boundingBoxes: BoundingBox[] = sortedRegions.map(({ region: detectedRegion, importance }) => {
const regionPath = svgElement.querySelector(`path[data-region-id="${detectedRegion.id}"]`)
if (!regionPath) {
return {
regionId: detectedRegion.id,
x: 0,
y: 0,
width: 0,
height: 0,
importance,
wasAccepted: false,
}
}
const pathRect = regionPath.getBoundingClientRect()
const regionSvgLeft = (pathRect.left - svgRect.left) * scaleX + viewBoxX
const regionSvgTop = (pathRect.top - svgRect.top) * scaleY + viewBoxY
return {
regionId: detectedRegion.id,
x: regionSvgLeft,
y: regionSvgTop,
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
importance,
wasAccepted: false,
}
}).filter((bbox) => bbox.width > 0 && bbox.height > 0)
// Search for optimal zoom // Search for optimal zoom
let optimalZoom = maxZoom let optimalZoom = maxZoom
@ -248,21 +358,21 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Clamp viewport to stay within map bounds // Clamp viewport to stay within map bounds
const viewport = clampViewportToMapBounds(initialViewport, mapBounds) const viewport = clampViewportToMapBounds(initialViewport, mapBounds)
// Check all detected regions to see if any are inside this viewport and fit nicely // Check regions in order of importance (most important first)
let foundFit = false let foundFit = false
for (const regionId of detectedRegions) { for (const { region: detectedRegion } of sortedRegions) {
const region = mapData.regions.find((r) => r.id === regionId) const region = mapData.regions.find((r) => r.id === detectedRegion.id)
if (!region) continue if (!region) continue
const regionPath = svgElement.querySelector(`path[data-region-id="${regionId}"]`) const regionPath = svgElement.querySelector(`path[data-region-id="${detectedRegion.id}"]`)
if (!regionPath) continue if (!regionPath) continue
// Use pre-computed largest piece size for multi-piece regions // Use pre-computed largest piece size for multi-piece regions
let currentWidth: number let currentWidth: number
let currentHeight: number let currentHeight: number
const cachedSize = largestPieceSizesCache.get(regionId) const cachedSize = largestPieceSizesCache.get(detectedRegion.id)
if (cachedSize) { if (cachedSize) {
// Multi-piece region: use pre-computed largest piece // Multi-piece region: use pre-computed largest piece
currentWidth = cachedSize.width currentWidth = cachedSize.width
@ -312,17 +422,14 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Log when we accept a zoom // Log when we accept a zoom
console.log( console.log(
`[Zoom] ✅ Accepted ${testZoom.toFixed(1)}x for ${regionId} (${currentWidth.toFixed(1)}px × ${currentHeight.toFixed(1)}px)` `[Zoom] ✅ Accepted ${testZoom.toFixed(1)}x for ${detectedRegion.id} (${currentWidth.toFixed(1)}px × ${currentHeight.toFixed(1)}px)`
) )
// Save bounding box for this region // Mark this region's bounding box as accepted
boundingBoxes.push({ const acceptedBox = boundingBoxes.find((bbox) => bbox.regionId === detectedRegion.id)
regionId, if (acceptedBox) {
x: regionSvgLeft, acceptedBox.wasAccepted = true
y: regionSvgTop, }
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
})
break // Found a good zoom, stop checking regions break // Found a good zoom, stop checking regions
} }