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": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -1,7 +1,7 @@
'use client'
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 { useTheme } from '@/contexts/ThemeContext'
import type { MapData, MapRegion } from '../types'
@ -19,7 +19,10 @@ import {
calculateMaxZoomAtThreshold,
isAboveThreshold,
} from '../utils/screenPixelRatio'
import { findOptimalZoom } from '../utils/adaptiveZoomSearch'
import {
findOptimalZoom,
type BoundingBox as DebugBoundingBox,
} from '../utils/adaptiveZoomSearch'
import { useRegionDetection } from '../hooks/useRegionDetection'
import { usePointerLock } from '../hooks/usePointerLock'
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
@ -27,6 +30,9 @@ import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
// Debug flag: show technical info in magnifier (dev only)
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
const PRECISION_MODE_THRESHOLD = 20
@ -125,7 +131,7 @@ export function MapRenderer({
guessHistory,
playerMetadata,
forceTuning = {},
showDebugBoundingBoxes = false,
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
}: MapRendererProps) {
// Extract force tuning parameters with defaults
const {
@ -249,9 +255,7 @@ export function MapRenderer({
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 }>
>([])
const [debugBoundingBoxes, setDebugBoundingBoxes] = useState<DebugBoundingBox[]>([])
// Pre-computed largest piece sizes for multi-piece regions
// Maps regionId -> {width, height} of the largest piece
@ -362,7 +366,6 @@ export function MapRenderer({
console.log('[CLICK] Using closest detected region:', {
regionId: closestRegion.id,
regionName: region.name,
distance: closestRegion.distanceToCenter?.toFixed(2),
})
onRegionClick(closestRegion.id, region.name)
}
@ -1048,10 +1051,7 @@ export function MapRenderer({
totalRegionArea,
} = detectionResult
// Extract region IDs for zoom search (already sorted smallest-first by hook)
const detectedRegions = detectedRegionObjects.map((r) => r.id)
if (pointerLocked && detectedRegions.length > 0) {
if (pointerLocked && detectedRegionObjects.length > 0) {
const sortedSizes = detectedRegionObjects.map((r) => `${r.id}: ${r.screenSize.toFixed(2)}px`)
console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes)
}
@ -1075,7 +1075,7 @@ export function MapRenderer({
if (shouldShow) {
// Use adaptive zoom search utility to find optimal zoom
const zoomSearchResult = findOptimalZoom({
detectedRegions,
detectedRegions: detectedRegionObjects,
detectedSmallestSize,
cursorX,
cursorY,
@ -1272,36 +1272,42 @@ 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>
))}
debugBoundingBoxes.map((bbox) => {
// Color based on acceptance and importance
// Green = accepted, Orange = high importance, Yellow = medium, Gray = low
const importance = bbox.importance ?? 0
let strokeColor = '#888888' // Default gray for low importance
let fillColor = 'rgba(136, 136, 136, 0.1)'
if (bbox.wasAccepted) {
strokeColor = '#00ff00' // Green for accepted region
fillColor = 'rgba(0, 255, 0, 0.15)'
} else if (importance > 1.5) {
strokeColor = '#ff6600' // Orange for high importance (2.0× boost + close)
fillColor = 'rgba(255, 102, 0, 0.1)'
} else if (importance > 0.5) {
strokeColor = '#ffcc00' // Yellow for medium importance
fillColor = 'rgba(255, 204, 0, 0.1)'
}
return (
<g key={`bbox-${bbox.regionId}`}>
<rect
x={bbox.x}
y={bbox.y}
width={bbox.width}
height={bbox.height}
fill={fillColor}
stroke={strokeColor}
strokeWidth={viewBoxWidth / 500}
vectorEffect="non-scaling-stroke"
strokeDasharray="3,3"
pointerEvents="none"
opacity={0.9}
/>
</g>
)
})}
{/* Arrow marker definition */}
<defs>
@ -1540,6 +1546,64 @@ export function MapRenderer({
</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 */}
{(() => {
// Debug logging removed - was flooding console
@ -2034,7 +2098,8 @@ export function MapRenderer({
<div>Regions detected: {detectedRegions.length}</div>
<div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div>
<div>
Smallest size: {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
Smallest size:{' '}
{detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
</div>
<div style={{ marginTop: '8px' }}>
<strong>Detected Regions:</strong>

View File

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

View File

@ -14,10 +14,11 @@
*/
import type { MapData } from '../types'
import type { DetectedRegion } from '../hooks/useRegionDetection'
export interface AdaptiveZoomSearchContext {
/** Detected region IDs, sorted by size (smallest first) */
detectedRegions: string[]
/** Detected region objects with size and metadata */
detectedRegions: DetectedRegion[]
/** Size of the smallest detected region in pixels */
detectedSmallestSize: number
/** Cursor position in container coordinates */
@ -43,12 +44,21 @@ export interface AdaptiveZoomSearchContext {
pointerLocked?: boolean
}
export interface Bounds {
left: number
right: number
top: number
bottom: number
}
export interface BoundingBox {
regionId: string
x: number
y: number
width: number
height: number
importance?: number
wasAccepted?: boolean
}
export interface AdaptiveZoomSearchResult {
@ -56,7 +66,7 @@ export interface AdaptiveZoomSearchResult {
zoom: number
/** Whether a good zoom was found (false = using minimum zoom as fallback) */
foundGoodZoom: boolean
/** Debug bounding boxes for visualization */
/** Debug bounding boxes for visualization (includes all detected regions) */
boundingBoxes: BoundingBox[]
}
@ -96,9 +106,9 @@ export function calculateAdaptiveThresholds(smallestSize: number): {
* @returns Clamped viewport bounds
*/
export function clampViewportToMapBounds(
viewport: { left: number; right: number; top: number; bottom: number },
mapBounds: { left: number; right: number; top: number; bottom: number }
): { left: number; right: number; top: number; bottom: number; wasClamped: boolean } {
viewport: Bounds,
mapBounds: Bounds
): Bounds & { wasClamped: boolean } {
let { left, right, top, bottom } = viewport
let wasClamped = false
@ -144,10 +154,7 @@ export function clampViewportToMapBounds(
* @param viewport - Viewport bounds in SVG coordinates
* @returns True if region overlaps viewport
*/
export function isRegionInViewport(
regionBounds: { left: number; right: number; top: number; bottom: number },
viewport: { left: number; right: number; top: number; bottom: number }
): boolean {
export function isRegionInViewport(regionBounds: Bounds, viewport: Bounds): boolean {
return (
regionBounds.left < viewport.right &&
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.
*
@ -225,8 +274,69 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
bottom: viewBoxY + viewBoxHeight,
}
// Track bounding boxes for debug visualization
const boundingBoxes: BoundingBox[] = []
// Calculate importance scores for all detected regions
// 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
let optimalZoom = maxZoom
@ -248,21 +358,21 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Clamp viewport to stay within map bounds
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
for (const regionId of detectedRegions) {
const region = mapData.regions.find((r) => r.id === regionId)
for (const { region: detectedRegion } of sortedRegions) {
const region = mapData.regions.find((r) => r.id === detectedRegion.id)
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
// Use pre-computed largest piece size for multi-piece regions
let currentWidth: number
let currentHeight: number
const cachedSize = largestPieceSizesCache.get(regionId)
const cachedSize = largestPieceSizesCache.get(detectedRegion.id)
if (cachedSize) {
// Multi-piece region: use pre-computed largest piece
currentWidth = cachedSize.width
@ -312,17 +422,14 @@ export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoo
// Log when we accept a zoom
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
boundingBoxes.push({
regionId,
x: regionSvgLeft,
y: regionSvgTop,
width: pathRect.width * scaleX,
height: pathRect.height * scaleY,
})
// Mark this region's bounding box as accepted
const acceptedBox = boundingBoxes.find((bbox) => bbox.regionId === detectedRegion.id)
if (acceptedBox) {
acceptedBox.wasAccepted = true
}
break // Found a good zoom, stop checking regions
}