1446 lines
53 KiB
TypeScript
1446 lines
53 KiB
TypeScript
'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
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = {},
|
||
}: 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),
|
||
})
|
||
|
||
return excluded
|
||
}, [mapData, selectedMap, selectedContinent])
|
||
|
||
// 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)
|
||
|
||
// Precision mode: automatic cursor dampening when over small regions
|
||
const lastRawCursorRef = useRef<{ x: number; y: number } | null>(null) // Raw mouse position
|
||
const dampenedCursorRef = useRef<{ x: number; y: number } | null>(null) // Dampened position
|
||
const lastMoveTimeRef = useRef<number>(Date.now())
|
||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||
const [precisionMode, setPrecisionMode] = useState(false)
|
||
const [superZoomActive, setSuperZoomActive] = useState(false)
|
||
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
|
||
|
||
// Configuration
|
||
const HOVER_DELAY_MS = 500 // Time to hover before super zoom activates
|
||
const QUICK_MOVE_THRESHOLD = 50 // Pixels per frame - exceeding this cancels dampening/zoom
|
||
const SUPER_ZOOM_MULTIPLIER = 2.5 // Super zoom is 2.5x the normal adaptive zoom
|
||
|
||
// Adaptive dampening based on smallest region size
|
||
// 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 getDampeningFactor = (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)
|
||
return 0.25 // Moderate precision for regions like Rhode Island (11px)
|
||
}
|
||
|
||
// Animated spring values for smooth transitions
|
||
// Different fade speeds: fast fade-in (100ms), slow fade-out (1000ms)
|
||
// Position animates with 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
|
||
}
|
||
// Position and zoom: medium speed
|
||
return { tension: 200, friction: 25 }
|
||
},
|
||
onChange: (result) => {
|
||
console.log('[Magnifier Spring] Animating:', {
|
||
opacity: result.value.opacity?.toFixed(2),
|
||
top: result.value.top?.toFixed(0),
|
||
left: result.value.left?.toFixed(0),
|
||
})
|
||
},
|
||
})
|
||
|
||
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 for ALL regions to diagnose threshold issues
|
||
console.log(
|
||
`[MapRenderer] ${isSmall ? '🔍 SMALL' : '📍 Regular'}: ${region.name} - ` +
|
||
`W:${pixelWidth.toFixed(1)}px H:${pixelHeight.toFixed(1)}px ` +
|
||
`Area:${pixelArea.toFixed(0)}px² (threshold: 10px)`
|
||
)
|
||
|
||
// 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
|
||
const cursorX = e.clientX - containerRect.left
|
||
const cursorY = e.clientY - containerRect.top
|
||
|
||
// Check if cursor is over the SVG
|
||
const isOverSvg =
|
||
e.clientX >= svgRect.left &&
|
||
e.clientX <= svgRect.right &&
|
||
e.clientY >= svgRect.top &&
|
||
e.clientY <= svgRect.bottom
|
||
|
||
// 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
|
||
}
|
||
|
||
// Calculate mouse velocity for quick-escape detection
|
||
const now = Date.now()
|
||
const timeDelta = now - lastMoveTimeRef.current
|
||
let velocity = 0
|
||
|
||
if (lastRawCursorRef.current && timeDelta > 0) {
|
||
const deltaX = cursorX - lastRawCursorRef.current.x
|
||
const deltaY = cursorY - lastRawCursorRef.current.y
|
||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||
velocity = distance // Distance in pixels (effectively pixels per frame)
|
||
}
|
||
|
||
// Quick escape: If moving fast, cancel dampening and super zoom
|
||
if (velocity > QUICK_MOVE_THRESHOLD) {
|
||
console.log(
|
||
`[Quick Escape] 💨 Fast movement detected (${velocity.toFixed(0)}px) - canceling precision mode and super zoom`
|
||
)
|
||
setPrecisionMode(false)
|
||
setSuperZoomActive(false)
|
||
if (hoverTimerRef.current) {
|
||
clearTimeout(hoverTimerRef.current)
|
||
hoverTimerRef.current = null
|
||
}
|
||
}
|
||
|
||
lastMoveTimeRef.current = now
|
||
|
||
// Apply cursor dampening in precision mode for better control over small regions
|
||
let finalCursorX = cursorX
|
||
let finalCursorY = cursorY
|
||
|
||
// Calculate adaptive dampening factor based on smallest region
|
||
const dampeningFactor = getDampeningFactor(smallestRegionSize)
|
||
|
||
console.log('[Precision Mode] Before dampening:', {
|
||
precisionMode,
|
||
hasLastRaw: !!lastRawCursorRef.current,
|
||
hasDampened: !!dampenedCursorRef.current,
|
||
rawCursor: { x: cursorX, y: cursorY },
|
||
velocity: velocity.toFixed(0) + 'px/frame',
|
||
smallestRegionSize: smallestRegionSize.toFixed(2),
|
||
dampeningFactor,
|
||
})
|
||
|
||
if (precisionMode && lastRawCursorRef.current && dampenedCursorRef.current) {
|
||
// Calculate delta from LAST RAW to CURRENT RAW (true velocity/direction)
|
||
const deltaX = cursorX - lastRawCursorRef.current.x
|
||
const deltaY = cursorY - lastRawCursorRef.current.y
|
||
|
||
// Apply dampening to the delta and add to last DAMPENED position
|
||
// This ensures instant direction changes without lag
|
||
finalCursorX = dampenedCursorRef.current.x + deltaX * dampeningFactor
|
||
finalCursorY = dampenedCursorRef.current.y + deltaY * dampeningFactor
|
||
|
||
console.log('[Precision Mode] ✅ DAMPENING ACTIVE:', {
|
||
rawCurrent: { x: cursorX.toFixed(2), y: cursorY.toFixed(2) },
|
||
rawLast: {
|
||
x: lastRawCursorRef.current.x.toFixed(2),
|
||
y: lastRawCursorRef.current.y.toFixed(2),
|
||
},
|
||
dampenedLast: {
|
||
x: dampenedCursorRef.current.x.toFixed(2),
|
||
y: dampenedCursorRef.current.y.toFixed(2),
|
||
},
|
||
delta: { x: deltaX.toFixed(2), y: deltaY.toFixed(2) },
|
||
dampenedNew: { x: finalCursorX.toFixed(2), y: finalCursorY.toFixed(2) },
|
||
smallestRegionSize: smallestRegionSize.toFixed(2) + 'px',
|
||
dampeningFactor: `${(dampeningFactor * 100).toFixed(0)}%`,
|
||
})
|
||
} else if (precisionMode) {
|
||
// First frame of precision mode - initialize dampened cursor at raw position
|
||
finalCursorX = cursorX
|
||
finalCursorY = cursorY
|
||
console.log('[Precision Mode] 🎯 INITIALIZING dampened cursor at raw position')
|
||
} else {
|
||
console.log('[Precision Mode] ❌ NO DAMPENING:', {
|
||
reason: !precisionMode ? 'precisionMode is false' : 'refs not initialized',
|
||
precisionMode,
|
||
hasLastRaw: !!lastRawCursorRef.current,
|
||
hasDampened: !!dampenedCursorRef.current,
|
||
})
|
||
}
|
||
|
||
// Update both cursor refs for next frame
|
||
lastRawCursorRef.current = { x: cursorX, y: cursorY }
|
||
dampenedCursorRef.current = { x: finalCursorX, y: finalCursorY }
|
||
|
||
setCursorPosition({ x: finalCursorX, y: finalCursorY })
|
||
|
||
// Define 50px × 50px detection box around cursor
|
||
const detectionBoxSize = 50
|
||
const halfBox = detectionBoxSize / 2
|
||
|
||
// Convert dampened cursor position back to client coordinates for region detection
|
||
// This ensures the detection box matches the crosshairs in the magnifier
|
||
const finalClientX = containerRect.left + finalCursorX
|
||
const finalClientY = containerRect.top + finalCursorY
|
||
|
||
console.log('[Region Detection] Using dampened cursor position:', {
|
||
raw: { x: e.clientX, y: e.clientY },
|
||
dampened: { x: finalClientX.toFixed(0), y: finalClientY.toFixed(0) },
|
||
delta: {
|
||
x: (finalClientX - e.clientX).toFixed(0),
|
||
y: (finalClientY - e.clientY).toFixed(0),
|
||
},
|
||
})
|
||
|
||
// Count regions in the detection box and track their sizes
|
||
let regionsInBox = 0
|
||
let hasSmallRegion = false
|
||
let totalRegionArea = 0
|
||
let detectedSmallestSize = Infinity
|
||
const detectedRegions: string[] = []
|
||
let regionUnderCursor: string | null = null
|
||
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 (using dampened cursor position)
|
||
const boxLeft = finalClientX - halfBox
|
||
const boxRight = finalClientX + halfBox
|
||
const boxTop = finalClientY - halfBox
|
||
const boxBottom = finalClientY + 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 dampened cursor is directly over this region
|
||
const cursorInRegion =
|
||
finalClientX >= regionLeft &&
|
||
finalClientX <= regionRight &&
|
||
finalClientY >= regionTop &&
|
||
finalClientY <= 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(
|
||
(finalClientX - regionCenterX) ** 2 + (finalClientY - regionCenterY) ** 2
|
||
)
|
||
|
||
if (distanceToCenter < smallestDistanceToCenter) {
|
||
smallestDistanceToCenter = distanceToCenter
|
||
regionUnderCursor = region.id
|
||
}
|
||
}
|
||
|
||
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
|
||
console.log('[Magnifier] Small region detected:', region.id, {
|
||
width: pixelWidth,
|
||
height: pixelHeight,
|
||
area: pixelArea,
|
||
})
|
||
}
|
||
|
||
// Track region sizes for adaptive zoom and dampening
|
||
totalRegionArea += pixelArea
|
||
const regionSize = Math.min(pixelWidth, pixelHeight)
|
||
detectedSmallestSize = Math.min(detectedSmallestSize, regionSize)
|
||
}
|
||
})
|
||
|
||
// Calculate adaptive zoom level based on region density and size
|
||
// Base zoom: 8x
|
||
// More regions = more zoom (up to +8x for 10+ regions)
|
||
// Smaller regions = more zoom (up to +8x for very tiny regions)
|
||
// Show magnifier if: 7+ regions in detection box OR any region smaller than 15px
|
||
const shouldShow = regionsInBox >= 7 || hasSmallRegion
|
||
|
||
// Update smallest region size for adaptive cursor dampening
|
||
if (shouldShow && detectedSmallestSize !== Infinity) {
|
||
setSmallestRegionSize(detectedSmallestSize)
|
||
} else {
|
||
setSmallestRegionSize(Infinity)
|
||
}
|
||
|
||
// Set hover highlighting based on dampened cursor position
|
||
// This ensures the crosshairs match what's highlighted
|
||
if (regionUnderCursor !== hoveredRegion) {
|
||
console.log('[Hover Detection] Region under dampened cursor:', {
|
||
region: regionUnderCursor,
|
||
regionName: regionUnderCursor
|
||
? mapData.regions.find((r) => r.id === regionUnderCursor)?.name
|
||
: null,
|
||
dampenedPos: { x: finalClientX.toFixed(0), y: finalClientY.toFixed(0) },
|
||
distanceToCenter: smallestDistanceToCenter.toFixed(2) + 'px',
|
||
})
|
||
setHoveredRegion(regionUnderCursor)
|
||
}
|
||
|
||
// Enable precision mode (cursor dampening) when magnifier is needed
|
||
// This gives users more control over tiny regions like Jersey
|
||
if (shouldShow !== precisionMode) {
|
||
console.log(
|
||
`[Precision Mode] ${shouldShow ? '🎯 ENABLING' : '❌ DISABLING'} precision mode (cursor dampening) | Smallest region: ${detectedSmallestSize.toFixed(2)}px`
|
||
)
|
||
}
|
||
setPrecisionMode(shouldShow)
|
||
|
||
// Auto super-zoom on hover: If hovering over sub-pixel regions (< 1px), start timer
|
||
const shouldEnableSuperZoom = detectedSmallestSize < 1 && shouldShow
|
||
if (shouldEnableSuperZoom && !hoverTimerRef.current && !superZoomActive) {
|
||
console.log(
|
||
`[Super Zoom] ⏱️ Starting hover timer (${HOVER_DELAY_MS}ms) for sub-pixel region: ${detectedSmallestSize.toFixed(2)}px`
|
||
)
|
||
hoverTimerRef.current = setTimeout(() => {
|
||
console.log('[Super Zoom] 🔍 ACTIVATING super zoom!')
|
||
setSuperZoomActive(true)
|
||
hoverTimerRef.current = null
|
||
}, HOVER_DELAY_MS)
|
||
} else if (!shouldEnableSuperZoom && hoverTimerRef.current) {
|
||
// Cancel timer if we move away from sub-pixel regions
|
||
console.log('[Super Zoom] ❌ Canceling hover timer (moved away from sub-pixel region)')
|
||
clearTimeout(hoverTimerRef.current)
|
||
hoverTimerRef.current = null
|
||
} else if (!shouldShow && superZoomActive) {
|
||
// Deactivate super zoom if we move away entirely
|
||
console.log('[Super Zoom] ❌ Deactivating super zoom (moved away from small regions)')
|
||
setSuperZoomActive(false)
|
||
}
|
||
|
||
// Debug logging
|
||
console.log('[Magnifier] Detection:', {
|
||
detectedRegions,
|
||
regionsInBox,
|
||
hasSmallRegion,
|
||
smallestRegionSize: detectedSmallestSize.toFixed(2) + 'px',
|
||
shouldShow,
|
||
precisionMode: shouldShow,
|
||
cursorPos: { x: e.clientX, y: e.clientY },
|
||
})
|
||
|
||
if (shouldShow) {
|
||
let adaptiveZoom = 8 // Base zoom
|
||
|
||
// Add zoom based on region count (crowded areas need more zoom)
|
||
const countFactor = Math.min(regionsInBox / 10, 1) // 0 to 1
|
||
adaptiveZoom += countFactor * 8
|
||
|
||
// Add zoom based on smallest region size (tiny regions need more zoom)
|
||
if (detectedSmallestSize !== Infinity) {
|
||
const sizeFactor = Math.max(0, 1 - detectedSmallestSize / 20) // 0 to 1 (1 = very small)
|
||
adaptiveZoom += sizeFactor * 8
|
||
}
|
||
|
||
// Clamp zoom between 8x and 24x (or higher if super zoom active)
|
||
const maxZoom = superZoomActive ? 60 : 24 // Super zoom can go up to 60x
|
||
adaptiveZoom = Math.max(8, Math.min(maxZoom, adaptiveZoom))
|
||
|
||
// Apply super zoom multiplier if active
|
||
if (superZoomActive) {
|
||
adaptiveZoom = Math.min(maxZoom, adaptiveZoom * SUPER_ZOOM_MULTIPLIER)
|
||
console.log(
|
||
`[Super Zoom] 🔍 Applied ${SUPER_ZOOM_MULTIPLIER}x multiplier: ${adaptiveZoom.toFixed(1)}x zoom`
|
||
)
|
||
}
|
||
|
||
// Calculate magnifier position (opposite corner from cursor)
|
||
const containerRect = containerRef.current.getBoundingClientRect()
|
||
const magnifierWidth = containerRect.width * 0.5
|
||
const magnifierHeight = magnifierWidth / 2
|
||
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
|
||
|
||
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 {
|
||
console.log('[Magnifier] HIDING - not enough regions or too large | Setting opacity to 0')
|
||
setShowMagnifier(false)
|
||
setTargetOpacity(0)
|
||
}
|
||
}
|
||
|
||
const handleMouseLeave = () => {
|
||
setShowMagnifier(false)
|
||
setTargetOpacity(0)
|
||
setCursorPosition(null)
|
||
setPrecisionMode(false)
|
||
setSuperZoomActive(false)
|
||
lastRawCursorRef.current = null
|
||
dampenedCursorRef.current = null
|
||
|
||
// Clear hover timer if active
|
||
if (hoverTimerRef.current) {
|
||
clearTimeout(hoverTimerRef.current)
|
||
hoverTimerRef.current = null
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
data-component="map-renderer"
|
||
onMouseMove={handleMouseMove}
|
||
onMouseLeave={handleMouseLeave}
|
||
className={css({
|
||
position: 'relative',
|
||
width: '100%',
|
||
maxWidth: '1000px',
|
||
margin: '0 auto',
|
||
padding: '4',
|
||
bg: isDark ? 'gray.900' : 'gray.50',
|
||
rounded: 'xl',
|
||
shadow: 'lg',
|
||
})}
|
||
>
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={mapData.viewBox}
|
||
className={css({
|
||
width: '100%',
|
||
height: 'auto',
|
||
cursor: precisionMode ? '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 precision mode is active, hover is controlled by dampened cursor position
|
||
// Otherwise, use native mouse events
|
||
onMouseEnter={() => !isExcluded && !precisionMode && setHoveredRegion(region.id)}
|
||
onMouseLeave={() => !precisionMode && 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>
|
||
)
|
||
})}
|
||
|
||
{/* 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>
|
||
))}
|
||
|
||
{/* Magnifier Window - Always rendered when cursor exists, opacity controlled by spring */}
|
||
{cursorPosition && svgRef.current && containerRef.current && (
|
||
<animated.div
|
||
data-element="magnifier"
|
||
data-super-zoom={superZoomActive}
|
||
style={{
|
||
position: 'absolute',
|
||
// Animated positioning - smoothly moves to opposite corner from cursor
|
||
top: magnifierSpring.top,
|
||
left: magnifierSpring.left,
|
||
width: '50%',
|
||
aspectRatio: '2/1',
|
||
// Super zoom gets gold border, normal mode gets blue border
|
||
border: superZoomActive
|
||
? `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: superZoomActive
|
||
? '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>
|
||
)
|
||
}
|