fix: use actual SVG path geometry for region detection instead of bounding boxes

CRITICAL FIX: Replace bounding box intersection tests with actual SVG path
geometry using isPointInFill(). This affects three critical systems:

1. **Region detection for auto-zoom** - No longer triggers on empty space within
   bounding boxes of irregularly shaped regions

2. **Hover highlighting** - Only highlights when cursor is actually over the
   region shape, not just its bounding box

3. **Click detection** - Only registers clicks on actual region geometry,
   preventing false positives

Implementation:
- Fast rejection: Check bounding box overlap first (performance optimization)
- Precise detection: Sample 5×5 grid (25 points) within detection box
- Use SVGGeometryElement.isPointInFill() for each sample point
- Direct cursor test: Check if cursor point is inside region fill
- Reuse SVGPoint object to avoid allocations in hot path

This fixes issues with regions like Italy (boot shape), Croatia (complex
coastline), and Malta (tiny region with large bounding box due to nearby islands).

Benefits:
- No false positives from empty space in bounding boxes
- More precise hover highlighting
- More accurate click detection
- Better auto-zoom targeting for small regions

🤖 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 19:34:07 -06:00
parent 3887dd9ab2
commit e255ce2c6f
1 changed files with 51 additions and 13 deletions

View File

@ -1,12 +1,18 @@
/**
* Region Detection Hook
*
* Detects which regions are near the cursor using a detection box.
* Detects which regions are near the cursor using actual SVG path geometry.
* Uses isPointInFill() to test if sample points within the detection box
* intersect with the actual region shapes (not just bounding boxes).
*
* Returns information about detected regions including:
* - Which regions overlap with the detection box
* - Which region is directly under the cursor
* - Which regions overlap with the detection box (using actual geometry)
* - Which region is directly under the cursor (using actual geometry)
* - Size information for adaptive cursor dampening
* - Whether there are small regions requiring magnifier zoom
*
* CRITICAL: All detection uses SVG path geometry via isPointInFill(), not
* bounding boxes. This prevents false positives from irregularly shaped regions.
*/
import { useState, useCallback, type RefObject } from 'react'
@ -141,31 +147,62 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
mapData.regions.forEach((region) => {
const regionPath = svgElement.querySelector(`path[data-region-id="${region.id}"]`)
if (!regionPath) return
if (!regionPath || !(regionPath instanceof SVGGeometryElement)) return
const pathRect = regionPath.getBoundingClientRect()
// CRITICAL: Use actual SVG path geometry, not bounding box
// Sample multiple points within the detection box to check for intersection
// This prevents false positives from irregularly shaped regions
// First quick check: does bounding box even overlap? (fast rejection)
const regionLeft = pathRect.left
const regionRight = pathRect.right
const regionTop = pathRect.top
const regionBottom = pathRect.bottom
// Check if region overlaps with detection box
const overlaps =
const boundingBoxOverlaps =
regionLeft < boxRight &&
regionRight > boxLeft &&
regionTop < boxBottom &&
regionBottom > boxTop
// Check if cursor is directly over this region
const cursorInRegion =
cursorClientX >= regionLeft &&
cursorClientX <= regionRight &&
cursorClientY >= regionTop &&
cursorClientY <= regionBottom
if (!boundingBoxOverlaps) {
// Bounding box doesn't overlap, so actual path definitely doesn't
return
}
// Bounding box overlaps - now check actual path geometry
// Sample a grid of points within the detection box
const samplesPerSide = 5 // 5×5 = 25 sample points
const sampleStep = detectionBoxSize / (samplesPerSide - 1)
let overlaps = false
let cursorInRegion = false
// Create SVG point for reuse
const svgPoint = svgElement.createSVGPoint()
// Check if cursor point is inside the actual region path
svgPoint.x = cursorClientX
svgPoint.y = cursorClientY
cursorInRegion = regionPath.isPointInFill(svgPoint)
// Sample points in detection box to check for overlap
for (let i = 0; i < samplesPerSide && !overlaps; i++) {
for (let j = 0; j < samplesPerSide && !overlaps; j++) {
svgPoint.x = boxLeft + i * sampleStep
svgPoint.y = boxTop + j * sampleStep
if (regionPath.isPointInFill(svgPoint)) {
overlaps = true
break
}
}
}
// If cursor is inside region, track it as region under cursor
if (cursorInRegion) {
// Calculate distance from cursor to region center
// Calculate distance from cursor to region center (using bounding box center as approximation)
const regionCenterX = (regionLeft + regionRight) / 2
const regionCenterY = (regionTop + regionBottom) / 2
const distanceToCenter = Math.sqrt(
@ -179,6 +216,7 @@ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegio
}
}
// If detection box overlaps with actual path geometry, add to detected regions
if (overlaps) {
const pixelWidth = pathRect.width
const pixelHeight = pathRect.height