soroban-abacus-flashcards/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx

1803 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(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<number>(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<Map<string, { width: number; height: number }>>(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<string, { width: number; height: number }>()
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<HTMLDivElement>) => {
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<RegionLabelPosition[]>([])
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<LabelNode | ObstacleNode>().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<LabelNode | ObstacleNode>((d) => {
if ('isObstacle' in d && d.isObstacle) return d.x
return (d as LabelNode).targetX
}).strength(centeringStrength)
)
.force(
'y',
forceY<LabelNode | ObstacleNode>((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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
data-component="map-renderer"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleContainerClick}
className={css({
position: 'relative',
width: '100%',
maxWidth: '1000px',
margin: '0 auto',
padding: '4',
bg: isDark ? 'gray.900' : 'gray.50',
rounded: 'xl',
shadow: 'lg',
})}
>
{/* Pointer Lock Prompt Overlay */}
{showLockPrompt && !pointerLocked && (
<div
data-element="pointer-lock-prompt"
className={css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bg: isDark ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.95)',
color: isDark ? 'white' : 'gray.900',
padding: '8',
rounded: 'xl',
border: '3px solid',
borderColor: 'blue.500',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translate(-50%, -50%) scale(1.05)',
borderColor: 'blue.400',
},
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '4' })}>🎯</div>
<div className={css({ fontSize: 'xl', fontWeight: 'bold', marginBottom: '2' })}>
Enable Precision Controls
</div>
<div className={css({ fontSize: 'sm', color: isDark ? 'gray.400' : 'gray.600' })}>
Click anywhere to lock cursor for precise control
</div>
</div>
)}
<svg
ref={svgRef}
viewBox={mapData.viewBox}
className={css({
width: '100%',
height: 'auto',
cursor: pointerLocked ? 'crosshair' : 'pointer',
})}
>
{/* Background */}
<rect x="0" y="0" width="100%" height="100%" fill={isDark ? '#111827' : '#f3f4f6'} />
{/* 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 (
<g key={region.id}>
{/* Region path */}
<path
data-region-id={region.id}
d={region.path}
fill={fill}
stroke={getRegionStroke(isFound, isDark)}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
opacity={showOutline(region) ? 1 : 0.7} // Increased from 0.3 to 0.7 for better visibility
// When pointer lock is active, hover is controlled by cursor position tracking
// Otherwise, use native mouse events
onMouseEnter={() => !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 */}
<circle
cx={region.center[0]}
cy={region.center[1]}
r={0.1}
fill="none"
pointerEvents="none"
data-ghost-region={region.id}
/>
</g>
)
})}
{/* Debug: Render bounding boxes (only if enabled) */}
{showDebugBoundingBoxes &&
debugBoundingBoxes.map((bbox) => (
<g key={`bbox-${bbox.regionId}`}>
<rect
x={bbox.x}
y={bbox.y}
width={bbox.width}
height={bbox.height}
fill="none"
stroke="#ff0000"
strokeWidth={viewBoxWidth / 500}
vectorEffect="non-scaling-stroke"
strokeDasharray="3,3"
pointerEvents="none"
opacity={0.8}
/>
{/* Label showing region ID */}
<text
x={bbox.x + bbox.width / 2}
y={bbox.y + bbox.height / 2}
fill="#ff0000"
fontSize={viewBoxWidth / 80}
textAnchor="middle"
dominantBaseline="middle"
pointerEvents="none"
style={{ fontWeight: 'bold' }}
>
{bbox.regionId}
</text>
</g>
))}
{/* Arrow marker definition */}
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<polygon points="0 0, 10 3, 0 6" fill={isDark ? '#60a5fa' : '#3b82f6'} />
</marker>
<marker
id="arrowhead-found"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="#16a34a" />
</marker>
{/* Player emoji patterns for region backgrounds */}
{Object.values(playerMetadata).map((player) => (
<pattern
key={`pattern-${player.id}`}
id={`player-pattern-${player.id}`}
width="60"
height="60"
patternUnits="userSpaceOnUse"
>
<rect width="60" height="60" fill={player.color} opacity="0.2" />
<text
x="30"
y="30"
fontSize="50"
textAnchor="middle"
dominantBaseline="middle"
opacity="0.5"
>
{player.emoji}
</text>
</pattern>
))}
</defs>
{/* Magnifier region indicator on main map */}
{showMagnifier && cursorPosition && svgRef.current && containerRef.current && (
<animated.rect
x={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 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}
/>
)}
</svg>
{/* HTML labels positioned absolutely over the SVG */}
{labelPositions.map((label) => (
<div
key={label.regionId}
style={{
position: 'absolute',
left: `${label.x}px`,
top: `${label.y}px`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 10,
}}
>
{/* Region name */}
<div
style={{
fontSize: '14px',
fontWeight: 'bold',
color: getLabelTextColor(isDark, true),
textShadow: getLabelTextShadow(isDark, true),
whiteSpace: 'nowrap',
textAlign: 'center',
pointerEvents: 'none',
}}
>
{label.regionName}
</div>
{/* Player avatars */}
{label.players.length > 0 && (
<div
style={{
display: 'flex',
gap: '2px',
marginTop: '2px',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
{label.players.map((playerId) => {
const player = playerMetadata[playerId]
if (!player) return null
return (
<div
key={playerId}
style={{
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: player.color || '#3b82f6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
opacity: 0.9,
pointerEvents: 'none',
}}
>
{player.emoji}
</div>
)
})}
</div>
)}
</div>
))}
{/* Small region labels with arrows positioned absolutely over the SVG */}
{smallRegionLabelPositions.map((label) => (
<div key={`small-${label.regionId}`}>
{/* Arrow line - use SVG positioned absolutely */}
<svg
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
}}
>
<line
x1={label.lineStartX}
y1={label.lineStartY}
x2={label.lineEndX}
y2={label.lineEndY}
stroke={label.isFound ? '#16a34a' : isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth={1.5}
markerEnd={label.isFound ? 'url(#arrowhead-found)' : 'url(#arrowhead)'}
/>
{/* Debug: Show arrow endpoint (region centroid) */}
<circle cx={label.lineEndX} cy={label.lineEndY} r={3} fill="red" opacity={0.8} />
</svg>
{/* Label box and text */}
<div
style={{
position: 'absolute',
left: `${label.labelX}px`,
top: `${label.labelY}px`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'all',
cursor: 'pointer',
zIndex: 20,
}}
onClick={() => onRegionClick(label.regionId, label.regionName)}
onMouseEnter={() => setHoveredRegion(label.regionId)}
onMouseLeave={() => setHoveredRegion(null)}
>
{/* Background box */}
<div
style={{
padding: '2px 5px',
backgroundColor: label.isFound
? isDark
? '#22c55e'
: '#86efac'
: isDark
? '#1f2937'
: '#ffffff',
border: `1px solid ${label.isFound ? '#16a34a' : isDark ? '#60a5fa' : '#3b82f6'}`,
borderRadius: '4px',
fontSize: '11px',
fontWeight: '600',
color: getLabelTextColor(isDark, label.isFound),
textShadow: label.isFound
? getLabelTextShadow(isDark, true)
: '0 0 2px rgba(0,0,0,0.5)',
whiteSpace: 'nowrap',
userSelect: 'none',
transition: 'all 0.2s ease',
}}
>
{label.regionName}
</div>
</div>
</div>
))}
{/* Custom Cursor - Visible when pointer lock is active */}
{(() => {
// Debug logging removed - was flooding console
return pointerLocked && cursorPosition ? (
<div
data-element="custom-cursor"
style={{
position: 'absolute',
left: `${cursorPosition.x}px`,
top: `${cursorPosition.y}px`,
width: '20px',
height: '20px',
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 200,
transform: 'translate(-50%, -50%)',
backgroundColor: 'transparent',
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
}}
>
{/* Crosshair - Vertical line */}
<div
style={{
position: 'absolute',
left: '50%',
top: '0',
width: '2px',
height: '100%',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateX(-50%)',
}}
/>
{/* Crosshair - Horizontal line */}
<div
style={{
position: 'absolute',
left: '0',
top: '50%',
width: '100%',
height: '2px',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateY(-50%)',
}}
/>
</div>
) : null
})()}
{/* Magnifier Window - Always rendered when cursor exists, opacity controlled by spring */}
{cursorPosition && svgRef.current && containerRef.current && (
<animated.div
data-element="magnifier"
style={{
position: 'absolute',
// Animated positioning - smoothly moves to opposite corner from cursor
top: magnifierSpring.top,
left: magnifierSpring.left,
width: '50%',
aspectRatio: '2/1',
// High zoom (>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,
}}
>
<animated.svg
viewBox={magnifierSpring.zoom.to((zoom) => {
// 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 */}
<rect x="0" y="0" width="100%" height="100%" fill={isDark ? '#111827' : '#f3f4f6'} />
{/* 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 (
<path
key={`mag-${region.id}`}
d={region.path}
fill={fill}
stroke={getRegionStroke(isFound, isDark)}
strokeWidth={0.5}
vectorEffect="non-scaling-stroke"
opacity={showOutline(region) ? 1 : 0.3}
/>
)
})}
{/* Crosshair at cursor position */}
<g>
{(() => {
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 (
<>
<circle
cx={cursorSvgX}
cy={cursorSvgY}
r={viewBoxWidth / 100}
fill="none"
stroke={isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth={viewBoxWidth / 500}
vectorEffect="non-scaling-stroke"
/>
<line
x1={cursorSvgX - viewBoxWidth / 50}
y1={cursorSvgY}
x2={cursorSvgX + viewBoxWidth / 50}
y2={cursorSvgY}
stroke={isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth={viewBoxWidth / 1000}
vectorEffect="non-scaling-stroke"
/>
<line
x1={cursorSvgX}
y1={cursorSvgY - viewBoxHeight / 50}
x2={cursorSvgX}
y2={cursorSvgY + viewBoxHeight / 50}
stroke={isDark ? '#60a5fa' : '#3b82f6'}
strokeWidth={viewBoxWidth / 1000}
vectorEffect="non-scaling-stroke"
/>
</>
)
})()}
</g>
</animated.svg>
{/* Magnifier label */}
<animated.div
style={{
position: 'absolute',
top: '8px',
left: '8px',
padding: '4px 8px',
background: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
borderRadius: '6px',
fontSize: '11px',
fontWeight: 'bold',
color: isDark ? '#60a5fa' : '#3b82f6',
pointerEvents: 'none',
}}
>
{magnifierSpring.zoom.to((z) => `${z.toFixed(1)}× Zoom`)}
</animated.div>
</animated.div>
)}
</div>
)
}