refactor: extract adaptive zoom search algorithm (Phase 1)

Create utility module for adaptive zoom search (~347 lines):

**Main function:**
- `findOptimalZoom()` - Find optimal zoom based on detected regions
  - Start from MAX_ZOOM (1000×) and reduce by 10% each iteration
  - Calculate magnified viewport at each zoom level
  - Clamp viewport to map bounds
  - Check if any detected region fits nicely in magnifier
  - Accept first zoom where region occupies adaptive% of magnifier
  - Return minimum zoom if no good zoom found

**Helper functions:**
- `calculateAdaptiveThresholds()` - Adaptive acceptance ratios
  - Sub-pixel (< 1px): 2-8% (for Gibraltar at 0.08px)
  - Tiny (1-5px): 5-15%
  - Normal small: 10-25%
- `clampViewportToMapBounds()` - Keep viewport within map
- `isRegionInViewport()` - Check region/viewport overlap

**Key features:**
- Support for multi-piece regions (uses cached largest piece)
- Debug bounding box tracking
- Comprehensive JSDoc documentation
- Pure functions (no side effects)

This extraction reduces MapRenderer.tsx complexity and makes the adaptive
zoom algorithm testable and reusable.

Part of Phase 1: Extract pure utilities from MapRenderer.

🤖 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 06:47:05 -06:00
parent a6fbb9c5ca
commit e2ad8fb901

View File

@@ -0,0 +1,347 @@
/**
* Adaptive Zoom Search
*
* This module implements the adaptive zoom search algorithm that finds the
* optimal zoom level based on detected region sizes. The algorithm starts from
* maximum zoom and reduces iteratively until a region fits nicely in the magnifier.
*
* Key features:
* - Adaptive acceptance thresholds based on smallest region size
* - Viewport clamping to map bounds
* - Region-in-viewport detection
* - Support for multi-piece regions (uses largest piece for sizing)
* - Debug bounding box tracking
*/
import type { MapData } from '../types'
export interface AdaptiveZoomSearchContext {
/** Detected region IDs, sorted by size (smallest first) */
detectedRegions: string[]
/** Size of the smallest detected region in pixels */
detectedSmallestSize: number
/** Cursor position in container coordinates */
cursorX: number
cursorY: number
/** Container bounding rect */
containerRect: DOMRect
/** SVG element bounding rect */
svgRect: DOMRect
/** Map data containing regions and viewBox */
mapData: MapData
/** SVG element reference for querying region paths */
svgElement: SVGSVGElement
/** Cache of largest piece sizes for multi-piece regions */
largestPieceSizesCache: Map<string, { width: number; height: number }>
/** Maximum zoom level (default: 1000) */
maxZoom?: number
/** Minimum zoom level (default: 1) */
minZoom?: number
/** Zoom step multiplier (default: 0.9 = reduce by 10% each iteration) */
zoomStep?: number
/** Whether pointer lock is active (for logging) */
pointerLocked?: boolean
}
export interface BoundingBox {
regionId: string
x: number
y: number
width: number
height: number
}
export interface AdaptiveZoomSearchResult {
/** The optimal zoom level found */
zoom: number
/** Whether a good zoom was found (false = using minimum zoom as fallback) */
foundGoodZoom: boolean
/** Debug bounding boxes for visualization */
boundingBoxes: BoundingBox[]
}
/**
* Calculate adaptive acceptance thresholds based on smallest region size.
*
* For ultra-small regions like Gibraltar (0.08px), we need lower acceptance
* thresholds because even at 1000× zoom they only occupy ~0.02% of the magnifier.
*
* @param smallestSize - Smallest detected region size in pixels
* @returns Min and max acceptable ratios (region size / magnifier size)
*/
export function calculateAdaptiveThresholds(smallestSize: number): {
min: number
max: number
} {
if (smallestSize < 1) {
// Sub-pixel regions: accept 2-8% of magnifier
return { min: 0.02, max: 0.08 }
}
if (smallestSize < 5) {
// Tiny regions (1-5px): accept 5-15% of magnifier
return { min: 0.05, max: 0.15 }
}
// Normal small regions: accept 10-25% of magnifier
return { min: 0.1, max: 0.25 }
}
/**
* Clamp viewport bounds to map bounds.
*
* When the cursor is near the map edge, the magnified viewport may extend beyond
* the map. This function shifts the viewport to keep it within map bounds.
*
* @param viewport - Initial viewport bounds
* @param mapBounds - Map bounds
* @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 } {
let { left, right, top, bottom } = viewport
let wasClamped = false
// If viewport extends beyond left edge, shift it right
if (left < mapBounds.left) {
const shift = mapBounds.left - left
left += shift
right += shift
wasClamped = true
}
// If viewport extends beyond right edge, shift it left
if (right > mapBounds.right) {
const shift = right - mapBounds.right
left -= shift
right -= shift
wasClamped = true
}
// If viewport extends beyond top edge, shift it down
if (top < mapBounds.top) {
const shift = mapBounds.top - top
top += shift
bottom += shift
wasClamped = true
}
// If viewport extends beyond bottom edge, shift it up
if (bottom > mapBounds.bottom) {
const shift = bottom - mapBounds.bottom
top -= shift
bottom -= shift
wasClamped = true
}
return { left, right, top, bottom, wasClamped }
}
/**
* Check if a region overlaps with the viewport.
*
* @param regionBounds - Region bounding box in SVG coordinates
* @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 {
return (
regionBounds.left < viewport.right &&
regionBounds.right > viewport.left &&
regionBounds.top < viewport.bottom &&
regionBounds.bottom > viewport.top
)
}
/**
* Find optimal zoom level for the detected regions.
*
* Algorithm:
* 1. Start from MAX_ZOOM and reduce by ZOOM_STEP each iteration
* 2. For each zoom level, calculate the magnified viewport
* 3. Clamp viewport to map bounds
* 4. Check if any detected region is inside viewport and fits nicely
* 5. "Fits nicely" = region occupies min-max% of magnifier (adaptive thresholds)
* 6. Accept first zoom level where a region fits
* 7. If no zoom found, return MIN_ZOOM as fallback
*
* @param context - Search context with regions, dimensions, and configuration
* @returns Optimal zoom level and debug information
*/
export function findOptimalZoom(context: AdaptiveZoomSearchContext): AdaptiveZoomSearchResult {
const {
detectedRegions,
detectedSmallestSize,
cursorX,
cursorY,
containerRect,
svgRect,
mapData,
svgElement,
largestPieceSizesCache,
maxZoom = 1000,
minZoom = 1,
zoomStep = 0.9,
pointerLocked = false,
} = context
// Calculate adaptive acceptance thresholds
const thresholds = calculateAdaptiveThresholds(detectedSmallestSize)
const { min: minAcceptableRatio, max: maxAcceptableRatio } = thresholds
if (pointerLocked) {
console.log('[Zoom Search] Adaptive thresholds:', {
detectedSmallestSize: `${detectedSmallestSize.toFixed(4)}px`,
minAcceptableRatio: `${(minAcceptableRatio * 100).toFixed(1)}%`,
maxAcceptableRatio: `${(maxAcceptableRatio * 100).toFixed(1)}%`,
})
}
// Parse viewBox
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
// Magnifier dimensions
const magnifierWidth = containerRect.width * 0.5
const magnifierHeight = magnifierWidth / 2
// Convert cursor position to SVG coordinates
const scaleX = viewBoxWidth / svgRect.width
const scaleY = viewBoxHeight / svgRect.height
const cursorSvgX = (cursorX - (svgRect.left - containerRect.left)) * scaleX + viewBoxX
const cursorSvgY = (cursorY - (svgRect.top - containerRect.top)) * scaleY + viewBoxY
// Map bounds for viewport clamping
const mapBounds = {
left: viewBoxX,
right: viewBoxX + viewBoxWidth,
top: viewBoxY,
bottom: viewBoxY + viewBoxHeight,
}
// Track bounding boxes for debug visualization
const boundingBoxes: BoundingBox[] = []
// Search for optimal zoom
let optimalZoom = maxZoom
let foundGoodZoom = false
for (let testZoom = maxZoom; testZoom >= minZoom; testZoom *= zoomStep) {
// 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
const initialViewport = {
left: cursorSvgX - magnifiedViewBoxWidth / 2,
right: cursorSvgX + magnifiedViewBoxWidth / 2,
top: cursorSvgY - magnifiedViewBoxHeight / 2,
bottom: cursorSvgY + magnifiedViewBoxHeight / 2,
}
// 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
let foundFit = false
for (const regionId of detectedRegions) {
const region = mapData.regions.find((r) => r.id === regionId)
if (!region) continue
const regionPath = svgElement.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 = largestPieceSizesCache.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 regionBounds = {
left: regionSvgLeft,
right: regionSvgRight,
top: regionSvgTop,
bottom: regionSvgBottom,
}
if (!isRegionInViewport(regionBounds, viewport)) {
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
// If either dimension is within our adaptive acceptance range, we found a good zoom
if (
(widthRatio >= minAcceptableRatio && widthRatio <= maxAcceptableRatio) ||
(heightRatio >= minAcceptableRatio && heightRatio <= maxAcceptableRatio)
) {
optimalZoom = testZoom
foundFit = true
foundGoodZoom = true
// Log when we 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
optimalZoom = minZoom
if (pointerLocked) {
console.log(`[Zoom Search] ⚠️ No good zoom found, using minimum: ${minZoom}x`)
}
}
return {
zoom: optimalZoom,
foundGoodZoom,
boundingBoxes,
}
}