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:
parent
0aee60d8d1
commit
e60a2c09c0
|
|
@ -83,7 +83,5 @@
|
|||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue