'use client' import { useState, useMemo, useRef, useEffect } from 'react' import { useSpring, animated } from '@react-spring/web' import { css } from '@styled/css' import { useTheme } from '@/contexts/ThemeContext' import type { MapData, MapRegion } from '../types' import { getRegionColor, getRegionStroke, getRegionStrokeWidth, getLabelTextColor, getLabelTextShadow, } from '../mapColors' import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force' import { getMapData, getFilteredMapData, filterRegionsByContinent } from '../maps' import type { ContinentId } from '../continents' interface BoundingBox { minX: number maxX: number minY: number maxY: number width: number height: number area: number } interface MapRendererProps { mapData: MapData regionsFound: string[] currentPrompt: string | null difficulty: string // Difficulty level ID selectedMap: 'world' | 'usa' // Map ID for calculating excluded regions selectedContinent: string // Continent ID for calculating excluded regions onRegionClick: (regionId: string, regionName: string) => void guessHistory: Array<{ playerId: string regionId: string correct: boolean }> playerMetadata: Record< string, { id: string name: string emoji: string color: string } > // Force simulation tuning parameters forceTuning?: { showArrows?: boolean centeringStrength?: number collisionPadding?: number simulationIterations?: number useObstacles?: boolean obstaclePadding?: number } // Debug flags showDebugBoundingBoxes?: boolean } /** * Calculate bounding box from SVG path string */ function calculateBoundingBox(pathString: string): BoundingBox { const numbers = pathString.match(/-?\d+\.?\d*/g)?.map(Number) || [] if (numbers.length === 0) { return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0, area: 0 } } const xCoords: number[] = [] const yCoords: number[] = [] for (let i = 0; i < numbers.length; i += 2) { xCoords.push(numbers[i]) if (i + 1 < numbers.length) { yCoords.push(numbers[i + 1]) } } const minX = Math.min(...xCoords) const maxX = Math.max(...xCoords) const minY = Math.min(...yCoords) const maxY = Math.max(...yCoords) const width = maxX - minX const height = maxY - minY const area = width * height return { minX, maxX, minY, maxY, width, height, area } } interface RegionLabelPosition { regionId: string regionName: string x: number // pixel position y: number // pixel position players: string[] } export function MapRenderer({ mapData, regionsFound, currentPrompt, difficulty, selectedMap, selectedContinent, onRegionClick, guessHistory, playerMetadata, forceTuning = {}, showDebugBoundingBoxes = false, }: MapRendererProps) { // Extract force tuning parameters with defaults const { showArrows = false, centeringStrength = 2.0, collisionPadding = 5, simulationIterations = 200, useObstacles = true, obstaclePadding = 10, } = forceTuning const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' // Calculate excluded regions (regions filtered out by difficulty/continent) const excludedRegions = useMemo(() => { // Get full unfiltered map data const fullMapData = getMapData(selectedMap) let allRegions = fullMapData.regions // Apply continent filter if world map if (selectedMap === 'world' && selectedContinent !== 'all') { allRegions = filterRegionsByContinent(allRegions, selectedContinent as ContinentId) } // Find regions in full data that aren't in filtered data const includedRegionIds = new Set(mapData.regions.map((r) => r.id)) const excluded = allRegions.filter((r) => !includedRegionIds.has(r.id)) console.log('[MapRenderer] Excluded regions by difficulty:', { total: allRegions.length, included: includedRegionIds.size, excluded: excluded.length, excludedNames: excluded.map((r) => r.name), }) // Debug: Check if Gibraltar is included or excluded const gibraltar = allRegions.find((r) => r.id === 'gi') const gibraltarIncluded = includedRegionIds.has('gi') if (gibraltar) { console.log('[Gibraltar Debug]', gibraltarIncluded ? '✅ INCLUDED' : '❌ EXCLUDED', { inFilteredMap: gibraltarIncluded, difficulty, continent: selectedContinent, }) } return excluded }, [mapData, selectedMap, selectedContinent, difficulty]) // Create a set of excluded region IDs for quick lookup const excludedRegionIds = useMemo( () => new Set(excludedRegions.map((r) => r.id)), [excludedRegions] ) const [hoveredRegion, setHoveredRegion] = useState(null) const svgRef = useRef(null) const containerRef = useRef(null) const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 }) const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null) const [showMagnifier, setShowMagnifier] = useState(false) const [targetZoom, setTargetZoom] = useState(10) const [targetOpacity, setTargetOpacity] = useState(0) const [targetTop, setTargetTop] = useState(20) const [targetLeft, setTargetLeft] = useState(20) // Pointer lock management const [pointerLocked, setPointerLocked] = useState(false) const [showLockPrompt, setShowLockPrompt] = useState(true) // Cursor position tracking (container-relative coordinates) const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) 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 }> >([]) // Pre-computed largest piece sizes for multi-piece regions // Maps regionId -> {width, height} of the largest piece const largestPieceSizesRef = useRef>(new Map()) // Configuration const MAX_ZOOM = 1000 // Maximum zoom level (for Gibraltar at 0.08px!) const HIGH_ZOOM_THRESHOLD = 100 // Show gold border above this zoom level // Movement speed multiplier based on smallest region size // When pointer lock is active, apply this multiplier to movementX/movementY // For sub-pixel regions (< 1px): 3% speed (ultra precision) // For tiny regions (1-5px): 10% speed (high precision) // For small regions (5-15px): 25% speed (moderate precision) const getMovementMultiplier = (size: number): number => { if (size < 1) return 0.03 // Ultra precision for sub-pixel regions like Gibraltar (0.08px) if (size < 5) return 0.1 // High precision for regions like Jersey (0.82px) if (size < 15) return 0.25 // Moderate precision for regions like Rhode Island (11px) return 1.0 // Normal speed for larger regions } // Set up pointer lock event listeners useEffect(() => { const handlePointerLockChange = () => { const isLocked = document.pointerLockElement === containerRef.current console.log('[MapRenderer] Pointer lock change event:', { isLocked, pointerLockElement: document.pointerLockElement, containerElement: containerRef.current, elementsMatch: document.pointerLockElement === containerRef.current, }) setPointerLocked(isLocked) if (isLocked) { setShowLockPrompt(false) // Hide prompt when locked } else { // Show prompt again when lock is released (e.g., user hit Escape) setShowLockPrompt(true) } } const handlePointerLockError = () => { console.error('[Pointer Lock] ❌ Failed to acquire pointer lock') setPointerLocked(false) } document.addEventListener('pointerlockchange', handlePointerLockChange) document.addEventListener('pointerlockerror', handlePointerLockError) console.log('[MapRenderer] Pointer lock listeners attached') return () => { document.removeEventListener('pointerlockchange', handlePointerLockChange) document.removeEventListener('pointerlockerror', handlePointerLockError) console.log('[MapRenderer] Pointer lock listeners removed') } }, []) // Release pointer lock when component unmounts useEffect(() => { return () => { if (document.pointerLockElement) { console.log('[Pointer Lock] 🔓 RELEASING (MapRenderer unmount)') document.exitPointerLock() } } }, []) // Pre-compute largest piece sizes for multi-piece regions useEffect(() => { if (!svgRef.current) return const largestPieceSizes = new Map() mapData.regions.forEach((region) => { const pathData = region.path const pieceSeparatorRegex = /(?<=z)\s*m\s*/i const rawPieces = pathData.split(pieceSeparatorRegex) if (rawPieces.length > 1) { // Multi-piece region: use the FIRST piece (mainland), not largest // The first piece is typically the mainland, with islands as subsequent pieces const svg = svgRef.current if (!svg) return // Just measure the first piece const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') tempPath.setAttribute('d', rawPieces[0]) // First piece already has 'm' command tempPath.style.visibility = 'hidden' svg.appendChild(tempPath) const bbox = tempPath.getBoundingClientRect() const firstPieceSize = { width: bbox.width, height: bbox.height } svg.removeChild(tempPath) largestPieceSizes.set(region.id, firstPieceSize) // Only log Portugal for debugging if (region.id === 'pt') { console.log( `[Pre-compute] ${region.id}: Using first piece (mainland): ${firstPieceSize.width.toFixed(2)}px × ${firstPieceSize.height.toFixed(2)}px` ) } } }) largestPieceSizesRef.current = largestPieceSizes }, [mapData]) // Request pointer lock on first click const handleContainerClick = (e: React.MouseEvent) => { console.log('[MapRenderer] Container clicked:', { pointerLocked, hasContainer: !!containerRef.current, showLockPrompt, willRequestLock: !pointerLocked && containerRef.current && showLockPrompt, target: e.target, }) if (!pointerLocked && containerRef.current && showLockPrompt) { console.log('[Pointer Lock] 🔒 REQUESTING pointer lock (user clicked map)') try { containerRef.current.requestPointerLock() console.log('[Pointer Lock] Request sent successfully') } catch (error) { console.error('[Pointer Lock] Request failed with error:', error) } setShowLockPrompt(false) // Hide prompt after first click attempt } } // Animated spring values for smooth transitions // Different fade speeds: fast fade-in (100ms), slow fade-out (1000ms) // Zoom: smooth, slower animation with gentle easing // Position: medium speed (300ms) const magnifierSpring = useSpring({ zoom: targetZoom, opacity: targetOpacity, top: targetTop, left: targetLeft, config: (key) => { if (key === 'opacity') { return targetOpacity === 1 ? { duration: 100 } // Fade in: 0.1 seconds : { duration: 1000 } // Fade out: 1 second } if (key === 'zoom') { // Zoom: smooth, slower animation with gentle easing return { tension: 120, friction: 30, mass: 1 } } // Position: medium speed return { tension: 200, friction: 25 } }, // onChange removed - was flooding console with animation frames }) const [labelPositions, setLabelPositions] = useState([]) const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState< Array<{ regionId: string regionName: string isFound: boolean labelX: number // pixel position for label labelY: number // pixel position for label lineStartX: number // pixel position for line start lineStartY: number // pixel position for line start lineEndX: number // pixel position for line end (region center) lineEndY: number // pixel position for line end }> >([]) // Measure SVG element to get actual pixel dimensions useEffect(() => { if (!svgRef.current) return const updateDimensions = () => { const rect = svgRef.current?.getBoundingClientRect() if (rect) { setSvgDimensions({ width: rect.width, height: rect.height }) } } // Initial measurement updateDimensions() // Update on window resize window.addEventListener('resize', updateDimensions) return () => window.removeEventListener('resize', updateDimensions) }, [mapData.viewBox]) // Re-measure when viewBox changes (continent/map selection) // Calculate label positions using ghost elements useEffect(() => { if (!svgRef.current || !containerRef.current) return const updateLabelPositions = () => { const containerRect = containerRef.current?.getBoundingClientRect() if (!containerRect) return const positions: RegionLabelPosition[] = [] const smallPositions: typeof smallRegionLabelPositions = [] // Parse viewBox for scale calculations 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 svgRect = svgRef.current?.getBoundingClientRect() if (!svgRect) return const scaleX = svgRect.width / viewBoxWidth const scaleY = svgRect.height / viewBoxHeight // Calculate SVG offset within container (accounts for padding) const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top // Collect all regions with their info for force simulation interface LabelNode extends SimulationNodeDatum { id: string name: string x: number y: number targetX: number targetY: number width: number height: number isFound: boolean isSmall: boolean players?: string[] } const allLabelNodes: LabelNode[] = [] // Process both included regions and excluded regions for labeling ;[...mapData.regions, ...excludedRegions].forEach((region) => { // Calculate centroid pixel position directly from SVG coordinates // Account for SVG offset within container (padding, etc.) const centroidPixelX = (region.center[0] - viewBoxX) * scaleX + svgOffsetX const centroidPixelY = (region.center[1] - viewBoxY) * scaleY + svgOffsetY const pixelX = centroidPixelX const pixelY = centroidPixelY // Get the actual region path element to measure its TRUE screen dimensions const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const pixelWidth = pathRect.width const pixelHeight = pathRect.height const pixelArea = pathRect.width * pathRect.height // Check if this is a small region using ACTUAL screen pixels const isSmall = pixelWidth < 10 || pixelHeight < 10 || pixelArea < 100 // Debug logging ONLY for Gibraltar and ultra-tiny regions (< 1px) if (region.id === 'gi' || pixelWidth < 1 || pixelHeight < 1) { console.log( `[MapRenderer] ${region.id === 'gi' ? '🎯 GIBRALTAR' : '🔍 TINY'}: ${region.name} - ` + `W:${pixelWidth.toFixed(2)}px H:${pixelHeight.toFixed(2)}px ` + `Area:${pixelArea.toFixed(2)}px²` ) } // Collect label nodes for regions that need labels // Only show arrow labels for small regions if showArrows flag is enabled // Exception: Washington DC always gets arrow label (too small on USA map) const isDC = region.id === 'dc' const isExcluded = excludedRegionIds.has(region.id) // Show label if: region is found, OR region is excluded (pre-found), OR it's small and arrows enabled const shouldShowLabel = regionsFound.includes(region.id) || isExcluded || (isSmall && (showArrows || isDC)) if (shouldShowLabel) { const players = regionsFound.includes(region.id) ? guessHistory .filter((guess) => guess.regionId === region.id && guess.correct) .map((guess) => guess.playerId) .filter((playerId, index, self) => self.indexOf(playerId) === index) : undefined const labelWidth = region.name.length * 7 + 15 const labelHeight = isSmall ? 25 : 30 // Regular found states (non-small) get positioned exactly at centroid // Only small regions go through force simulation if (isSmall) { allLabelNodes.push({ id: region.id, name: region.name, x: pixelX, // Start directly on region - will spread out to avoid collisions y: pixelY, targetX: pixelX, // Anchor point to pull back toward targetY: pixelY, width: labelWidth, height: labelHeight, isFound: regionsFound.includes(region.id), isSmall, players, }) } else { // Add directly to positions array - no force simulation positions.push({ regionId: region.id, regionName: region.name, x: pixelX, y: pixelY, players: players || [], }) } } }) // Add region obstacles to repel labels away from the map itself interface ObstacleNode extends SimulationNodeDatum { id: string x: number y: number isObstacle: true radius: number } const obstacleNodes: ObstacleNode[] = [] // Add all regions (including unlabeled ones) as obstacles (if enabled) if (useObstacles) { mapData.regions.forEach((region) => { const ghostElement = svgRef.current?.querySelector(`[data-ghost-region="${region.id}"]`) if (!ghostElement) return const ghostRect = ghostElement.getBoundingClientRect() const pixelX = ghostRect.left - containerRect.left + ghostRect.width / 2 const pixelY = ghostRect.top - containerRect.top + ghostRect.height / 2 const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const regionRadius = Math.max(pathRect.width, pathRect.height) / 2 obstacleNodes.push({ id: `obstacle-${region.id}`, isObstacle: true, x: pixelX, y: pixelY, radius: regionRadius + obstaclePadding, }) }) } // Combine labels and obstacles for simulation const allNodes = [...allLabelNodes, ...obstacleNodes] // Run force simulation to position labels without overlaps if (allLabelNodes.length > 0) { const simulation = forceSimulation(allNodes) .force( 'collide', forceCollide().radius((d) => { if ('isObstacle' in d && d.isObstacle) { return (d as ObstacleNode).radius } const label = d as LabelNode return Math.max(label.width, label.height) / 2 + collisionPadding }) ) .force( 'x', forceX((d) => { if ('isObstacle' in d && d.isObstacle) return d.x return (d as LabelNode).targetX }).strength(centeringStrength) ) .force( 'y', forceY((d) => { if ('isObstacle' in d && d.isObstacle) return d.y return (d as LabelNode).targetY }).strength(centeringStrength) ) .stop() // Run simulation - labels start on regions and only move as needed for (let i = 0; i < simulationIterations; i++) { simulation.tick() } // Helper: Calculate arrow start point on label edge closest to region const getArrowStartPoint = ( labelX: number, labelY: number, labelWidth: number, labelHeight: number, targetX: number, targetY: number ): { x: number; y: number } => { // Direction from label to region const dx = targetX - labelX const dy = targetY - labelY // Label edges const halfWidth = labelWidth / 2 const halfHeight = labelHeight / 2 // Calculate intersection with label box // Use parametric line equation: point = (labelX, labelY) + t * (dx, dy) // Find t where line intersects rectangle edges let bestT = 0 const epsilon = 1e-10 // Check each edge if (Math.abs(dx) > epsilon) { // Right edge: x = labelX + halfWidth const tRight = halfWidth / dx if (tRight > 0 && tRight <= 1) { const y = labelY + tRight * dy if (Math.abs(y - labelY) <= halfHeight) { bestT = tRight } } // Left edge: x = labelX - halfWidth const tLeft = -halfWidth / dx if (tLeft > 0 && tLeft <= 1) { const y = labelY + tLeft * dy if (Math.abs(y - labelY) <= halfHeight) { if (bestT === 0 || tLeft < bestT) bestT = tLeft } } } if (Math.abs(dy) > epsilon) { // Bottom edge: y = labelY + halfHeight const tBottom = halfHeight / dy if (tBottom > 0 && tBottom <= 1) { const x = labelX + tBottom * dx if (Math.abs(x - labelX) <= halfWidth) { if (bestT === 0 || tBottom < bestT) bestT = tBottom } } // Top edge: y = labelY - halfHeight const tTop = -halfHeight / dy if (tTop > 0 && tTop <= 1) { const x = labelX + tTop * dx if (Math.abs(x - labelX) <= halfWidth) { if (bestT === 0 || tTop < bestT) bestT = tTop } } } return { x: labelX + bestT * dx, y: labelY + bestT * dy, } } // Extract positions from simulation results (only small regions now) for (const node of allLabelNodes) { // Special handling for Washington DC - position off the map to avoid blocking other states if (node.id === 'dc') { // Position DC label to the right of the map, outside the main map area const containerWidth = containerRect.width const labelX = containerWidth - 80 // 80px from right edge const labelY = svgOffsetY + svgRect.height * 0.35 // Upper-middle area const arrowStart = getArrowStartPoint( labelX, labelY, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: labelX, labelY: labelY, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) continue // Skip normal processing } // All remaining nodes are small regions (non-small are added directly to positions) const arrowStart = getArrowStartPoint( node.x!, node.y!, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: node.x!, labelY: node.y!, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) } } setLabelPositions(positions) setSmallRegionLabelPositions(smallPositions) // Debug: Log summary console.log('[MapRenderer] Label positions updated:', { mapId: mapData.id, totalRegions: mapData.regions.length, regularLabels: positions.length, smallRegionLabels: smallPositions.length, viewBox: mapData.viewBox, svgDimensions, }) } // Small delay to ensure ghost elements are rendered const timeoutId = setTimeout(updateLabelPositions, 0) // Update on resize window.addEventListener('resize', updateLabelPositions) return () => { clearTimeout(timeoutId) window.removeEventListener('resize', updateLabelPositions) } }, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds]) // Calculate viewBox dimensions for label offset calculations const viewBoxParts = mapData.viewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 const showOutline = (region: MapRegion): boolean => { // Easy mode: always show outlines if (difficulty === 'easy') return true // Medium/Hard mode: only show outline on hover or if found return hoveredRegion === region.id || regionsFound.includes(region.id) } // Helper: Get the player who found a specific region const getPlayerWhoFoundRegion = (regionId: string): string | null => { const guess = guessHistory.find((g) => g.regionId === regionId && g.correct) return guess?.playerId || null } // Handle mouse movement to track cursor and show magnifier when needed const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current || !containerRef.current) return const containerRect = containerRef.current.getBoundingClientRect() const svgRect = svgRef.current.getBoundingClientRect() // Get cursor position relative to container let cursorX: number let cursorY: number if (pointerLocked) { // When pointer is locked, use movement deltas with precision multiplier const lastX = cursorPositionRef.current?.x ?? containerRect.width / 2 const lastY = cursorPositionRef.current?.y ?? containerRect.height / 2 // Apply movement multiplier based on smallest region size for precision control const movementMultiplier = getMovementMultiplier(smallestRegionSize) cursorX = lastX + e.movementX * movementMultiplier cursorY = lastY + e.movementY * movementMultiplier // Clamp to container bounds cursorX = Math.max(0, Math.min(containerRect.width, cursorX)) cursorY = Math.max(0, Math.min(containerRect.height, cursorY)) } else { // Normal mode: use absolute position cursorX = e.clientX - containerRect.left cursorY = e.clientY - containerRect.top } // Check if cursor is over the SVG const isOverSvg = cursorX >= svgRect.left - containerRect.left && cursorX <= svgRect.right - containerRect.left && cursorY >= svgRect.top - containerRect.top && cursorY <= svgRect.bottom - containerRect.top // Don't hide magnifier if mouse is still in container (just moved to padding/magnifier area) // Only update cursor position and check for regions if over SVG if (!isOverSvg) { // Keep magnifier visible but frozen at last position // It will be hidden by handleMouseLeave when mouse exits container return } // No velocity tracking needed - zoom adapts immediately to region size // Update cursor position ref for next frame cursorPositionRef.current = { x: cursorX, y: cursorY } setCursorPosition({ x: cursorX, y: cursorY }) // Define 50px × 50px detection box around cursor const detectionBoxSize = 50 const halfBox = detectionBoxSize / 2 // Convert cursor position to client coordinates for region detection const cursorClientX = containerRect.left + cursorX const cursorClientY = containerRect.top + cursorY // Count regions in the detection box and track their sizes let regionsInBox = 0 let hasSmallRegion = false let totalRegionArea = 0 let detectedSmallestSize = Infinity // For dampening (smallest in detection box) const detectedRegions: string[] = [] let regionUnderCursor: string | null = null let regionUnderCursorArea = 0 // For zoom calculation (area of hovered region) let smallestDistanceToCenter = Infinity mapData.regions.forEach((region) => { const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() // Check if region overlaps with detection box const boxLeft = cursorClientX - halfBox const boxRight = cursorClientX + halfBox const boxTop = cursorClientY - halfBox const boxBottom = cursorClientY + halfBox const regionLeft = pathRect.left const regionRight = pathRect.right const regionTop = pathRect.top const regionBottom = pathRect.bottom const overlaps = regionLeft < boxRight && regionRight > boxLeft && regionTop < boxBottom && regionBottom > boxTop // Also check if cursor is directly over this region const cursorInRegion = cursorClientX >= regionLeft && cursorClientX <= regionRight && cursorClientY >= regionTop && cursorClientY <= regionBottom if (cursorInRegion) { // Calculate distance from cursor to region center to find the "best" match 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 (overlaps) { regionsInBox++ detectedRegions.push(region.id) // Check if this region is very small (threshold tuned for Rhode Island ~11px) const pixelWidth = pathRect.width const pixelHeight = pathRect.height const pixelArea = pathRect.width * pathRect.height const isVerySmall = pixelWidth < 15 || pixelHeight < 15 || pixelArea < 200 if (isVerySmall) { hasSmallRegion = true } // Track smallest region size for cursor dampening (use smallest in detection box) const screenSize = Math.min(pixelWidth, pixelHeight) totalRegionArea += pixelArea detectedSmallestSize = Math.min(detectedSmallestSize, screenSize) } }) // Sort detected regions by size (smallest first) to prioritize tiny regions in zoom calculation // This ensures Gibraltar (0.08px) is checked before Spain (81px) when finding optimal zoom detectedRegions.sort((a, b) => { const pathA = svgRef.current?.querySelector(`path[data-region-id="${a}"]`) const pathB = svgRef.current?.querySelector(`path[data-region-id="${b}"]`) if (!pathA || !pathB) return 0 const rectA = pathA.getBoundingClientRect() const rectB = pathB.getBoundingClientRect() // Use smallest dimension (width or height) for comparison const sizeA = Math.min(rectA.width, rectA.height) const sizeB = Math.min(rectB.width, rectB.height) return sizeA - sizeB // Smallest first }) if (pointerLocked && detectedRegions.length > 0) { const sortedSizes = detectedRegions.map((id) => { const path = svgRef.current?.querySelector(`path[data-region-id="${id}"]`) if (!path) return `${id}: ?` const rect = path.getBoundingClientRect() const size = Math.min(rect.width, rect.height) return `${id}: ${size.toFixed(2)}px` }) console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes) } // Calculate adaptive zoom level based on region density and size // Base zoom: 8x // More regions = more zoom (up to +8x for 10+ regions) // Show magnifier only when there are small regions (< 15px) const shouldShow = hasSmallRegion // Update smallest region size for adaptive cursor dampening if (shouldShow && detectedSmallestSize !== Infinity) { setSmallestRegionSize(detectedSmallestSize) } else { setSmallestRegionSize(Infinity) } // Set hover highlighting based on cursor position // This ensures the crosshairs match what's highlighted if (regionUnderCursor !== hoveredRegion) { setHoveredRegion(regionUnderCursor) } // Magnifier detection logging removed for performance if (shouldShow) { // Adaptive threshold based on smallest detected region // For ultra-small regions (< 1px), we need a lower acceptance threshold // Otherwise Gibraltar (0.08px) will never fit the 10-25% range even at 1000x zoom let minAcceptableRatio = 0.1 // Default: 10% minimum let maxAcceptableRatio = 0.25 // Default: 25% maximum if (detectedSmallestSize < 1) { // Sub-pixel regions: accept 2-8% of magnifier minAcceptableRatio = 0.02 maxAcceptableRatio = 0.08 } else if (detectedSmallestSize < 5) { // Tiny regions (1-5px): accept 5-15% of magnifier minAcceptableRatio = 0.05 maxAcceptableRatio = 0.15 } if (pointerLocked) { console.log('[Zoom Search] Adaptive thresholds:', { detectedSmallestSize: detectedSmallestSize.toFixed(4) + 'px', minAcceptableRatio: (minAcceptableRatio * 100).toFixed(1) + '%', maxAcceptableRatio: (maxAcceptableRatio * 100).toFixed(1) + '%', }) } // Zoom-out approach: Start from max zoom and reduce until a region fits nicely // Goal: Find zoom where any region occupies ~15% of magnifier width or height const TARGET_RATIO = 0.15 // Region should occupy 15% of magnifier dimension // Get SVG viewBox for bounding box conversion const viewBoxParts = mapData.viewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 // Magnifier dimensions const magnifierWidth = containerRect.width * 0.5 const magnifierHeight = magnifierWidth / 2 // Calculate target sizes: region should be this big in magnifier const targetWidthPx = magnifierWidth * TARGET_RATIO const targetHeightPx = magnifierHeight * TARGET_RATIO // Track bounding boxes for debug visualization const boundingBoxes: Array<{ regionId: string x: number y: number width: number height: number }> = [] // Start from max zoom and work down until we find a good fit let adaptiveZoom = MAX_ZOOM let foundGoodZoom = false // We'll test zoom levels by halving each time to find a good range quickly const MIN_ZOOM = 1 const ZOOM_STEP = 0.9 // Reduce by 10% each iteration // Convert cursor position to SVG coordinates const scaleX = viewBoxWidth / svgRect.width const scaleY = viewBoxHeight / svgRect.height const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const cursorSvgX = (cursorX - (svgRect.left - containerRect.left)) * scaleX + viewBoxX const cursorSvgY = (cursorY - (svgRect.top - containerRect.top)) * scaleY + viewBoxY // Zoom search logging disabled for performance for (let testZoom = MAX_ZOOM; testZoom >= MIN_ZOOM; testZoom *= ZOOM_STEP) { // 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, but clamped to map bounds let viewportLeft = cursorSvgX - magnifiedViewBoxWidth / 2 let viewportRight = cursorSvgX + magnifiedViewBoxWidth / 2 let viewportTop = cursorSvgY - magnifiedViewBoxHeight / 2 let viewportBottom = cursorSvgY + magnifiedViewBoxHeight / 2 // Clamp viewport to stay within map bounds const mapLeft = viewBoxX const mapRight = viewBoxX + viewBoxWidth const mapTop = viewBoxY const mapBottom = viewBoxY + viewBoxHeight let wasClamped = false const originalViewport = { left: viewportLeft, right: viewportRight, top: viewportTop, bottom: viewportBottom, } // If viewport extends beyond left edge, shift it right if (viewportLeft < mapLeft) { const shift = mapLeft - viewportLeft viewportLeft += shift viewportRight += shift wasClamped = true } // If viewport extends beyond right edge, shift it left if (viewportRight > mapRight) { const shift = viewportRight - mapRight viewportLeft -= shift viewportRight -= shift wasClamped = true } // If viewport extends beyond top edge, shift it down if (viewportTop < mapTop) { const shift = mapTop - viewportTop viewportTop += shift viewportBottom += shift wasClamped = true } // If viewport extends beyond bottom edge, shift it up if (viewportBottom > mapBottom) { const shift = viewportBottom - mapBottom viewportTop -= shift viewportBottom -= shift wasClamped = true } // Viewport logging disabled for performance // Check all detected regions to see if any are inside this viewport and fit nicely let foundFit = false const regionsChecked: Array<{ id: string; inside: boolean; ratio?: number }> = [] for (const regionId of detectedRegions) { const region = mapData.regions.find((r) => r.id === regionId) if (!region) continue const regionPath = svgRef.current?.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 = largestPieceSizesRef.current.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 isInsideViewport = regionSvgLeft < viewportRight && regionSvgRight > viewportLeft && regionSvgTop < viewportBottom && regionSvgBottom > viewportTop regionsChecked.push({ id: regionId, inside: isInsideViewport }) if (!isInsideViewport) 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 // Update the checked region data with ratio regionsChecked[regionsChecked.length - 1].ratio = Math.max(widthRatio, heightRatio) // If either dimension is within our adaptive acceptance range, we found a good zoom if ( (widthRatio >= minAcceptableRatio && widthRatio <= maxAcceptableRatio) || (heightRatio >= minAcceptableRatio && heightRatio <= maxAcceptableRatio) ) { adaptiveZoom = testZoom foundFit = true foundGoodZoom = true // Only log when we actually 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 adaptiveZoom = MIN_ZOOM if (pointerLocked) { console.log(`[Zoom Search] ⚠️ No good zoom found, using minimum: ${MIN_ZOOM}x`) } } // Save bounding boxes for rendering setDebugBoundingBoxes(boundingBoxes) // Calculate magnifier position (opposite corner from cursor) // magnifierWidth and magnifierHeight already declared above const isLeftHalf = cursorX < containerRect.width / 2 const isTopHalf = cursorY < containerRect.height / 2 const newTop = isTopHalf ? containerRect.height - magnifierHeight - 20 : 20 const newLeft = isLeftHalf ? containerRect.width - magnifierWidth - 20 : 20 if (pointerLocked) { console.log( '[Magnifier] SHOWING with zoom:', adaptiveZoom, '| Setting opacity to 1, position:', { top: newTop, left: newLeft } ) } setTargetZoom(adaptiveZoom) setShowMagnifier(true) setTargetOpacity(1) setTargetTop(newTop) setTargetLeft(newLeft) } else { if (pointerLocked) { console.log('[Magnifier] HIDING - not enough regions or too large | Setting opacity to 0') } setShowMagnifier(false) setTargetOpacity(0) setDebugBoundingBoxes([]) // Clear bounding boxes when hiding } } const handleMouseLeave = () => { // Don't hide magnifier when pointer is locked // The cursor is locked to the container, so mouse leave events are not meaningful if (pointerLocked) { console.log('[Mouse Leave] Ignoring - pointer is locked') return } setShowMagnifier(false) setTargetOpacity(0) setCursorPosition(null) setDebugBoundingBoxes([]) // Clear bounding boxes when leaving cursorPositionRef.current = null } return (
{/* Pointer Lock Prompt Overlay */} {showLockPrompt && !pointerLocked && (
🎯
Enable Precision Controls
Click anywhere to lock cursor for precise control
)} {/* Background */} {/* Render all regions (included + excluded) */} {[...mapData.regions, ...excludedRegions].map((region) => { const isExcluded = excludedRegionIds.has(region.id) const isFound = regionsFound.includes(region.id) || isExcluded // Treat excluded as pre-found const playerId = !isExcluded && isFound ? getPlayerWhoFoundRegion(region.id) : null // Special styling for excluded regions (grayed out, pre-labeled) const fill = isExcluded ? isDark ? '#374151' // gray-700 : '#d1d5db' // gray-300 : isFound && playerId ? `url(#player-pattern-${playerId})` : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) return ( {/* Region path */} !isExcluded && !pointerLocked && setHoveredRegion(region.id)} onMouseLeave={() => !pointerLocked && setHoveredRegion(null)} onClick={() => !isExcluded && onRegionClick(region.id, region.name)} // Disable clicks on excluded regions style={{ cursor: isExcluded ? 'default' : 'pointer', transition: 'all 0.2s ease', }} /> {/* Ghost element for region center position tracking */} ) })} {/* Debug: Render bounding boxes (only if enabled) */} {showDebugBoundingBoxes && debugBoundingBoxes.map((bbox) => ( {/* Label showing region ID */} {bbox.regionId} ))} {/* Arrow marker definition */} {/* Player emoji patterns for region backgrounds */} {Object.values(playerMetadata).map((player) => ( {player.emoji} ))} {/* Magnifier region indicator on main map */} {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( { 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 = viewBoxWidth / svgRect.width const scaleY = viewBoxHeight / svgRect.height const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX const magnifiedWidth = viewBoxWidth / zoom return cursorSvgX - magnifiedWidth / 2 })} y={magnifierSpring.zoom.to((zoom) => { 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 = viewBoxWidth / svgRect.width const scaleY = viewBoxHeight / svgRect.height const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY const magnifiedHeight = viewBoxHeight / zoom return cursorSvgY - magnifiedHeight / 2 })} width={magnifierSpring.zoom.to((zoom) => { const viewBoxParts = mapData.viewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] || 1000 return viewBoxWidth / zoom })} height={magnifierSpring.zoom.to((zoom) => { const viewBoxParts = mapData.viewBox.split(' ').map(Number) const viewBoxHeight = viewBoxParts[3] || 1000 return viewBoxHeight / zoom })} fill="none" stroke={isDark ? '#60a5fa' : '#3b82f6'} strokeWidth={viewBoxWidth / 500} vectorEffect="non-scaling-stroke" strokeDasharray="5,5" pointerEvents="none" opacity={0.8} /> )} {/* HTML labels positioned absolutely over the SVG */} {labelPositions.map((label) => (
{/* Region name */}
{label.regionName}
{/* Player avatars */} {label.players.length > 0 && (
{label.players.map((playerId) => { const player = playerMetadata[playerId] if (!player) return null return (
{player.emoji}
) })}
)}
))} {/* Small region labels with arrows positioned absolutely over the SVG */} {smallRegionLabelPositions.map((label) => (
{/* Arrow line - use SVG positioned absolutely */} {/* Debug: Show arrow endpoint (region centroid) */} {/* Label box and text */}
onRegionClick(label.regionId, label.regionName)} onMouseEnter={() => setHoveredRegion(label.regionId)} onMouseLeave={() => setHoveredRegion(null)} > {/* Background box */}
{label.regionName}
))} {/* Custom Cursor - Visible when pointer lock is active */} {(() => { // Debug logging removed - was flooding console return pointerLocked && cursorPosition ? (
{/* Crosshair - Vertical line */}
{/* Crosshair - Horizontal line */}
) : null })()} {/* Magnifier Window - Always rendered when cursor exists, opacity controlled by spring */} {cursorPosition && svgRef.current && containerRef.current && ( 60x) gets gold border, normal zoom gets blue border border: magnifierSpring.zoom.to( (zoom) => zoom > HIGH_ZOOM_THRESHOLD ? `4px solid ${isDark ? '#fbbf24' : '#f59e0b'}` // gold-400/gold-500 : `3px solid ${isDark ? '#60a5fa' : '#3b82f6'}` // blue-400/blue-600 ), borderRadius: '12px', overflow: 'hidden', pointerEvents: 'none', zIndex: 100, boxShadow: magnifierSpring.zoom.to((zoom) => zoom > HIGH_ZOOM_THRESHOLD ? '0 10px 40px rgba(251, 191, 36, 0.4), 0 0 20px rgba(251, 191, 36, 0.2)' // Gold glow : '0 10px 40px rgba(0, 0, 0, 0.5)' ), background: isDark ? '#111827' : '#f3f4f6', opacity: magnifierSpring.opacity, }} > { // Calculate magnified viewBox centered on cursor const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() // Convert cursor position to SVG coordinates 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 = viewBoxWidth / svgRect.width const scaleY = viewBoxHeight / svgRect.height // Cursor position relative to SVG const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY // Magnified view: adaptive zoom (using animated value) const magnifiedWidth = viewBoxWidth / zoom const magnifiedHeight = viewBoxHeight / zoom // Center the magnified viewBox on the cursor const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2 const magnifiedViewBoxY = cursorSvgY - magnifiedHeight / 2 return `${magnifiedViewBoxX} ${magnifiedViewBoxY} ${magnifiedWidth} ${magnifiedHeight}` })} style={{ width: '100%', height: '100%', }} > {/* Background */} {/* Render all regions in magnified view */} {mapData.regions.map((region) => { const isFound = regionsFound.includes(region.id) const playerId = isFound ? getPlayerWhoFoundRegion(region.id) : null const fill = isFound && playerId ? `url(#player-pattern-${playerId})` : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) return ( ) })} {/* Crosshair at cursor position */} {(() => { 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 = viewBoxWidth / svgRect.width const scaleY = viewBoxHeight / svgRect.height const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY return ( <> ) })()} {/* Magnifier label */} {magnifierSpring.zoom.to((z) => `${z.toFixed(1)}× Zoom`)} )}
) }