From e60a2c09c0e843af9a5a82433493ccf499660981 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 24 Nov 2025 18:24:29 -0600 Subject: [PATCH] feat: add visual debugging for zoom importance scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/.claude/settings.local.json | 4 +- .../components/MapRenderer.tsx | 151 ++++++++++++----- .../utils/adaptiveZoomSearch.test.ts | 4 +- .../utils/adaptiveZoomSearch.ts | 159 +++++++++++++++--- 4 files changed, 243 insertions(+), 75 deletions(-) diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 310e244c..f15bb8f7 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -83,7 +83,5 @@ "ask": [] }, "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "sqlite" - ] + "enabledMcpjsonServers": ["sqlite"] } diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index f663d6a8..cd5d5553 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -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(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([]) // 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) => ( - - - {/* Label showing region ID */} - - {bbox.regionId} - - - ))} + 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 ( + + + + ) + })} {/* Arrow marker definition */} @@ -1540,6 +1546,64 @@ export function MapRenderer({ ))} + {/* 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 ( +
+
{bbox.regionId}
+
{importance.toFixed(2)}
+
+ ) + })} + {/* Custom Cursor - Visible when pointer lock is active */} {(() => { // Debug logging removed - was flooding console @@ -2034,7 +2098,8 @@ export function MapRenderer({
Regions detected: {detectedRegions.length}
Has small region: {hasSmallRegion ? 'YES' : 'NO'}
- Smallest size: {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`} + Smallest size:{' '} + {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
Detected Regions: diff --git a/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.test.ts b/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.test.ts index fbf84c74..b5ab00db 100644 --- a/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.test.ts +++ b/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.test.ts @@ -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)) }) }) }) diff --git a/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.ts b/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.ts index 0c5a050b..adc11bee 100644 --- a/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.ts +++ b/apps/web/src/arcade-games/know-your-world/utils/adaptiveZoomSearch.ts @@ -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 }