Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | /** * Region Detection Hook * * Detects which regions are near the cursor using bounding box overlap. * Uses isPointInFill() to determine which region is directly under the cursor. * * Returns information about detected regions including: * - Which regions overlap with the detection box (using bounding box) * - Which region is directly under the cursor (using isPointInFill) * - Size information for adaptive cursor dampening * - Whether there are small regions requiring magnifier zoom */ import { useCallback, useRef, useEffect, type RefObject } from 'react' import type { MapData } from '../types' export interface DetectionBox { left: number right: number top: number bottom: number size: number } export interface DetectedRegion { id: string pixelWidth: number pixelHeight: number pixelArea: number isVerySmall: boolean screenSize: number } export interface RegionDetectionResult { /** Regions detected in the detection box, sorted by size (smallest first) */ detectedRegions: DetectedRegion[] /** The region directly under the cursor (closest to center) */ regionUnderCursor: string | null /** Area of the region under cursor */ regionUnderCursorArea: number /** Total number of regions in detection box */ regionsInBox: number /** Whether at least one small region was detected */ hasSmallRegion: boolean /** Smallest detected region size (for cursor dampening) */ detectedSmallestSize: number /** Total area of all detected regions */ totalRegionArea: number } export interface UseRegionDetectionOptions { /** The SVG element containing the regions */ svgRef: RefObject<SVGSVGElement> /** The container element (for coordinate conversion) */ containerRef: RefObject<HTMLDivElement> /** Map data containing regions */ mapData: MapData /** Size of the detection box (default: 50px) */ detectionBoxSize?: number /** Threshold for considering a region "very small" (default: 15px) */ smallRegionThreshold?: number /** Area threshold for small regions (default: 200px²) */ smallRegionAreaThreshold?: number /** Cache of pre-computed sizes for multi-piece regions (mainland only) */ largestPieceSizesCache?: Map<string, { width: number; height: number }> /** Regions that have been found - excluded from zoom level calculations */ regionsFound?: string[] } export interface UseRegionDetectionReturn { /** Detect regions at the given cursor position */ detectRegions: (cursorX: number, cursorY: number) => RegionDetectionResult // Note: hoveredRegion and setHoveredRegion were removed. // The interaction state machine is now authoritative for hovered region state. // Consumers should use interaction.hoveredRegionId instead. } /** * Custom hook for detecting regions near the cursor. * * This hook provides region detection logic for the magnifier: * - Detects regions within a box around the cursor * - Identifies the region directly under the cursor * - Sorts regions by size (smallest first) * - Tracks region sizes for adaptive behaviors * * @param options - Configuration options * @returns Region detection methods and state */ export function useRegionDetection(options: UseRegionDetectionOptions): UseRegionDetectionReturn { const { svgRef, containerRef, mapData, detectionBoxSize = 50, smallRegionThreshold = 15, smallRegionAreaThreshold = 200, largestPieceSizesCache, regionsFound = [], } = options // Cache path elements to avoid repeated querySelector calls const pathElementCache = useRef<Map<string, SVGGeometryElement>>(new Map()) // Populate path element cache when SVG is available useEffect(() => { const svgElement = svgRef.current if (!svgElement) return pathElementCache.current.clear() for (const region of mapData.regions) { const path = svgElement.querySelector(`path[data-region-id="${region.id}"]`) if (path && path instanceof SVGGeometryElement) { pathElementCache.current.set(region.id, path) } } }, [svgRef, mapData]) /** * Detect regions at the given cursor position. * * Returns information about all regions in the detection box, * including which region is under the cursor and size metrics. */ const detectRegions = useCallback( (cursorX: number, cursorY: number): RegionDetectionResult => { const svgElement = svgRef.current const containerElement = containerRef.current if (!svgElement || !containerElement) { return { detectedRegions: [], regionUnderCursor: null, regionUnderCursorArea: 0, regionsInBox: 0, hasSmallRegion: false, detectedSmallestSize: Infinity, totalRegionArea: 0, } } const containerRect = containerElement.getBoundingClientRect() const halfBox = detectionBoxSize / 2 // Convert cursor position to client coordinates const cursorClientX = containerRect.left + cursorX const cursorClientY = containerRect.top + cursorY // Detection box bounds const boxLeft = cursorClientX - halfBox const boxRight = cursorClientX + halfBox const boxTop = cursorClientY - halfBox const boxBottom = cursorClientY + halfBox // Track detected regions const detected: DetectedRegion[] = [] let regionUnderCursor: string | null = null let regionUnderCursorArea = 0 let smallestDistanceToCenter = Infinity let hasSmallRegion = false let totalRegionArea = 0 let detectedSmallestSize = Infinity // Get SVG transformation for converting region centers to screen coords const screenCTM = svgElement.getScreenCTM() if (!screenCTM) { return { detectedRegions: [], regionUnderCursor: null, regionUnderCursorArea: 0, regionsInBox: 0, hasSmallRegion: false, detectedSmallestSize: Infinity, totalRegionArea: 0, } } mapData.regions.forEach((region) => { // Get cached path element (populated in useEffect) const regionPath = pathElementCache.current.get(region.id) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() // Check if bounding box overlaps with detection box // This is efficient and works correctly for regions of all sizes // (unlike center-distance checks which fail for large regions like Russia) const regionLeft = pathRect.left const regionRight = pathRect.right const regionTop = pathRect.top const regionBottom = pathRect.bottom const boundingBoxOverlaps = regionLeft < boxRight && regionRight > boxLeft && regionTop < boxBottom && regionBottom > boxTop if (!boundingBoxOverlaps) { // Bounding box doesn't overlap, so actual path definitely doesn't return } // SIMPLE AND FAST: Use bounding box overlap for detection // If bounding box overlaps, the region is detected const overlaps = boundingBoxOverlaps // Use the screenCTM we already got at the top (guaranteed non-null) const inverseMatrix = screenCTM.inverse() // Check if cursor point is inside the actual region path (for "region under cursor") let svgPoint = svgElement.createSVGPoint() svgPoint.x = cursorClientX svgPoint.y = cursorClientY svgPoint = svgPoint.matrixTransform(inverseMatrix) const cursorInRegion = regionPath.isPointInFill(svgPoint) // If cursor is inside region, track it as region under cursor if (cursorInRegion) { // 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( (cursorClientX - regionCenterX) ** 2 + (cursorClientY - regionCenterY) ** 2 ) if (distanceToCenter < smallestDistanceToCenter) { smallestDistanceToCenter = distanceToCenter regionUnderCursor = region.id regionUnderCursorArea = pathRect.width * pathRect.height } } // If detection box overlaps with actual path geometry, add to detected regions if (overlaps) { // Use cached size for multi-piece regions (mainland only, not full bounding box) const cachedSize = largestPieceSizesCache?.get(region.id) const pixelWidth = cachedSize?.width ?? pathRect.width const pixelHeight = cachedSize?.height ?? pathRect.height const pixelArea = pixelWidth * pixelHeight const isVerySmall = pixelWidth < smallRegionThreshold || pixelHeight < smallRegionThreshold || pixelArea < smallRegionAreaThreshold const screenSize = Math.min(pixelWidth, pixelHeight) // Only count unfound regions toward zoom calculations // Found regions shouldn't influence hasSmallRegion or detectedSmallestSize const isFound = regionsFound.includes(region.id) if (!isFound) { if (isVerySmall) { hasSmallRegion = true } totalRegionArea += pixelArea detectedSmallestSize = Math.min(detectedSmallestSize, screenSize) } detected.push({ id: region.id, pixelWidth, pixelHeight, pixelArea, isVerySmall, screenSize, }) } }) // Sort detected regions by size (smallest first) detected.sort((a, b) => a.screenSize - b.screenSize) return { detectedRegions: detected, regionUnderCursor, regionUnderCursorArea, regionsInBox: detected.length, hasSmallRegion, detectedSmallestSize, totalRegionArea, } }, [ svgRef, containerRef, mapData, detectionBoxSize, smallRegionThreshold, smallRegionAreaThreshold, largestPieceSizesCache, regionsFound, ] ) return { detectRegions, } } |