'use client' import { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useSpring, animated, to } from '@react-spring/web' import { css } from '@styled/css' import { useTheme } from '@/contexts/ThemeContext' import type { MapData, MapRegion } from '../types' import { getRegionColor, getRegionStroke, getLabelTextColor, getLabelTextShadow, } from '../mapColors' import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force' import { WORLD_MAP, USA_MAP, filterRegionsByContinent, parseViewBox, calculateFitCropViewBox, } from '../maps' import type { ContinentId } from '../continents' import { calculateScreenPixelRatio, calculateMaxZoomAtThreshold, isAboveThreshold, } from '../utils/screenPixelRatio' import { findOptimalZoom, type BoundingBox as DebugBoundingBox } from '../utils/adaptiveZoomSearch' import { useRegionDetection } from '../hooks/useRegionDetection' import { usePointerLock } from '../hooks/usePointerLock' import { useMagnifierZoom } from '../hooks/useMagnifierZoom' import { DevCropTool } from './DevCropTool' // Debug flag: show technical info in magnifier (dev only) const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development' // Debug flag: show bounding boxes with importance scores (dev only) const SHOW_DEBUG_BOUNDING_BOXES = process.env.NODE_ENV === 'development' // Precision mode threshold: screen pixel ratio that triggers pointer lock recommendation const PRECISION_MODE_THRESHOLD = 20 // Label fade settings: labels fade near cursor to reduce clutter const LABEL_FADE_RADIUS = 150 // pixels - labels within this radius fade const LABEL_MIN_OPACITY = 0.08 // minimum opacity for faded labels // Magnifier size as fraction of container dimensions (1/3 of width and height) const MAGNIFIER_SIZE_RATIO = 1 / 3 /** * Calculate label opacity based on distance from cursor and animation state. * Labels fade to low opacity when cursor is near to reduce visual clutter. * During give-up animation, all labels are hidden so the flashing region is visible. * Exception: If cursor is over a found region, that region's label stays visible. */ function calculateLabelOpacity( labelX: number, labelY: number, labelRegionId: string, cursorPosition: { x: number; y: number } | null, hoveredRegion: string | null, regionsFound: string[], isGiveUpAnimating: boolean ): number { // During give-up animation, hide all labels so the flashing region is clearly visible if (isGiveUpAnimating) return 0 // No cursor position = full opacity if (!cursorPosition) return 1 // If hovering over this label's region AND it's been found, show at full opacity if (hoveredRegion === labelRegionId && regionsFound.includes(labelRegionId)) { return 1 } // Calculate distance from cursor to label const dx = labelX - cursorPosition.x const dy = labelY - cursorPosition.y const distance = Math.sqrt(dx * dx + dy * dy) // Outside fade radius = full opacity if (distance >= LABEL_FADE_RADIUS) return 1 // Inside fade radius = interpolate from min to full based on distance const t = distance / LABEL_FADE_RADIUS return LABEL_MIN_OPACITY + t * (1 - LABEL_MIN_OPACITY) } 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 userId?: string // Session ID that owns this player } > // Give up reveal animation giveUpReveal: { regionId: string regionName: string timestamp: number } | null // Give up callback onGiveUp: () => void // Force simulation tuning parameters forceTuning?: { showArrows?: boolean centeringStrength?: number collisionPadding?: number simulationIterations?: number useObstacles?: boolean obstaclePadding?: number } // Debug flags showDebugBoundingBoxes?: boolean // Multiplayer cursor sharing gameMode?: 'cooperative' | 'race' | 'turn-based' currentPlayer?: string // The player whose turn it is (for turn-based mode) localPlayerId?: string // The local player's ID (to filter out our own cursor from others) otherPlayerCursors?: Record onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void // Unanimous give-up voting (for cooperative multiplayer) giveUpVotes?: string[] // Session/viewer IDs (userIds) who have voted to give up activeUserIds?: string[] // All unique session IDs participating (to show "1/2 sessions voted") viewerId?: string // This viewer's userId (to check if local session has voted) // Member players mapping (userId -> players) for cursor emoji display memberPlayers?: Record> } /** * 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, giveUpReveal, onGiveUp, forceTuning = {}, showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES, gameMode, currentPlayer, localPlayerId, otherPlayerCursors = {}, onCursorUpdate, giveUpVotes = [], activeUserIds = [], viewerId, memberPlayers = {}, }: 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 = selectedMap === 'world' ? WORLD_MAP : USA_MAP 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 svgRef = useRef(null) const containerRef = useRef(null) // Pre-computed largest piece sizes for multi-piece regions // Maps regionId -> {width, height} of the largest piece // Defined early because useRegionDetection needs it const largestPieceSizesRef = useRef>(new Map()) // Region detection hook const { detectRegions, hoveredRegion, setHoveredRegion } = useRegionDetection({ svgRef, containerRef, mapData, detectionBoxSize: 50, smallRegionThreshold: 15, smallRegionAreaThreshold: 200, largestPieceSizesCache: largestPieceSizesRef.current, regionsFound, }) // State that needs to be available for hooks const cursorPositionRef = useRef<{ x: number; y: number } | null>(null) const initialCapturePositionRef = useRef<{ x: number; y: number } | null>(null) // Ref to track Give Up button bounds for pointer lock click detection const giveUpButtonBoundsRef = useRef<{ left: number top: number right: number bottom: number } | null>(null) // Track if fake cursor is hovering over Give Up button (for pointer lock mode) const [isFakeCursorOverGiveUp, setIsFakeCursorOverGiveUp] = useState(false) const [cursorSquish, setCursorSquish] = useState({ x: 1, y: 1 }) const [isReleasingPointerLock, setIsReleasingPointerLock] = useState(false) // Memoize pointer lock callbacks to prevent render loop const handleLockAcquired = useCallback(() => { // Save initial cursor position if (cursorPositionRef.current) { initialCapturePositionRef.current = { ...cursorPositionRef.current } console.log( '[Pointer Lock] 📍 Saved initial capture position:', initialCapturePositionRef.current ) } // Note: Zoom update now handled by useMagnifierZoom hook }, []) const handleLockReleased = useCallback(() => { console.log('[Pointer Lock] 🔓 RELEASED - Starting cleanup') // Reset cursor squish setCursorSquish({ x: 1, y: 1 }) setIsReleasingPointerLock(false) // Reset fake cursor hover state setIsFakeCursorOverGiveUp(false) // Note: Zoom recalculation now handled by useMagnifierZoom hook }, []) // Pointer lock hook (needed by zoom hook) const { pointerLocked, requestPointerLock, exitPointerLock } = usePointerLock({ containerRef, onLockAcquired: handleLockAcquired, onLockReleased: handleLockReleased, }) // Magnifier zoom hook const { targetZoom, setTargetZoom, zoomSpring, getCurrentZoom, uncappedAdaptiveZoomRef } = useMagnifierZoom({ containerRef, svgRef, viewBox: mapData.viewBox, threshold: PRECISION_MODE_THRESHOLD, pointerLocked, initialZoom: 10, }) const [svgDimensions, setSvgDimensions] = useState({ width: 1000, height: 500 }) const [cursorPosition, setCursorPosition] = useState<{ x: number; y: number } | null>(null) const [showMagnifier, setShowMagnifier] = useState(false) const [targetOpacity, setTargetOpacity] = useState(0) const [targetTop, setTargetTop] = useState(20) const [targetLeft, setTargetLeft] = useState(20) const [smallestRegionSize, setSmallestRegionSize] = useState(Infinity) const [shiftPressed, setShiftPressed] = useState(false) // Track whether current target region needs magnification const [targetNeedsMagnification, setTargetNeedsMagnification] = useState(false) // Give up reveal animation state const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value const [isGiveUpAnimating, setIsGiveUpAnimating] = useState(false) // Track if animation in progress // Saved button position to prevent jumping during zoom animation const [savedButtonPosition, setSavedButtonPosition] = useState<{ top: number right: number } | null>(null) // Debug: Track bounding boxes for visualization const [debugBoundingBoxes, setDebugBoundingBoxes] = useState([]) // Debug: Track full zoom search result for detailed panel const [zoomSearchDebugInfo, setZoomSearchDebugInfo] = useState | null>(null) // 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 } // Pre-compute largest piece sizes for multi-piece regions useEffect(() => { if (!svgRef.current) return const largestPieceSizes = new Map() mapData.regions.forEach((region) => { const pathData = region.path const pieceSeparatorRegex = /(?<=z)\s*m\s*/i const rawPieces = pathData.split(pieceSeparatorRegex) if (rawPieces.length > 1) { // Multi-piece region: use the FIRST piece (mainland), not largest // The first piece is typically the mainland, with islands as subsequent pieces const svg = svgRef.current if (!svg) return // Just measure the first piece const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') tempPath.setAttribute('d', rawPieces[0]) // First piece already has 'm' command tempPath.style.visibility = 'hidden' svg.appendChild(tempPath) const bbox = tempPath.getBoundingClientRect() const firstPieceSize = { width: bbox.width, height: bbox.height } svg.removeChild(tempPath) largestPieceSizes.set(region.id, firstPieceSize) // Log multi-piece regions for debugging if (region.id === 'pt' || region.id === 'nl') { console.log( `[Pre-compute] ${region.id}: ${rawPieces.length} pieces, using first piece: ${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) => { // Silently request pointer lock if not already locked // This makes the first gameplay click also enable precision mode if (!pointerLocked) { requestPointerLock() console.log('[Pointer Lock] 🔒 Silently requested (user clicked map)') return // Don't process region click on the first click that requests lock } // When pointer lock is active, browser doesn't deliver click events to SVG children // We need to manually detect which region is under the cursor if (pointerLocked && cursorPositionRef.current && containerRef.current && svgRef.current) { const { x: cursorX, y: cursorY } = cursorPositionRef.current console.log('[CLICK] Pointer lock click at cursor position:', { cursorX, cursorY }) // Check if clicking on Give Up button (pointer lock mode requires manual detection) const buttonBounds = giveUpButtonBoundsRef.current if ( buttonBounds && !isGiveUpAnimating && cursorX >= buttonBounds.left && cursorX <= buttonBounds.right && cursorY >= buttonBounds.top && cursorY <= buttonBounds.bottom ) { console.log('[CLICK] Give Up button clicked via pointer lock') onGiveUp() return } // Use the same detection logic as hover tracking (50px detection box) // This checks the main map SVG at the cursor position const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY) console.log('[CLICK] Detection results:', { detectedRegions: detectedRegions.map((r) => r.id), regionUnderCursor, detectedCount: detectedRegions.length, }) if (regionUnderCursor) { // Find the region data to get the name const region = mapData.regions.find((r) => r.id === regionUnderCursor) if (region) { console.log('[CLICK] Detected region under cursor:', { regionId: regionUnderCursor, regionName: region.name, }) onRegionClick(regionUnderCursor, region.name) } else { console.log('[CLICK] Region ID found but not in mapData:', regionUnderCursor) } } else { // No region directly under cursor - don't count as a click // This prevents sea/ocean clicks from being counted as wrong guesses console.log('[CLICK] No region under cursor - click ignored (sea/empty area)') } } } // Animated spring values for smooth transitions // Note: Zoom animation is now handled by useMagnifierZoom hook // This spring only handles: opacity, position, and movement multiplier const [magnifierSpring, magnifierApi] = useSpring( () => ({ opacity: targetOpacity, top: targetTop, left: targetLeft, movementMultiplier: getMovementMultiplier(smallestRegionSize), config: (key) => { if (key === 'opacity') { return targetOpacity === 1 ? { duration: 100 } // Fade in: 0.1 seconds : { duration: 1000 } // Fade out: 1 second } if (key === 'movementMultiplier') { // Movement multiplier: slower transitions for smooth damping changes // Lower tension = slower animation, higher friction = less overshoot return { tension: 60, friction: 20 } } // Position: medium speed return { tension: 200, friction: 25 } }, }), [targetOpacity, targetTop, targetLeft, smallestRegionSize] ) // Calculate the display viewBox using fit-crop-with-fill strategy // This ensures the custom crop region is visible while filling the container const displayViewBox = useMemo(() => { // If no custom crop, use the regular viewBox (which may be a calculated bounding box) if (!mapData.customCrop) { return mapData.viewBox } // Need container dimensions to calculate aspect ratio if (svgDimensions.width <= 0 || svgDimensions.height <= 0) { return mapData.viewBox } const containerAspect = svgDimensions.width / svgDimensions.height const originalBounds = parseViewBox(mapData.originalViewBox) const cropRegion = parseViewBox(mapData.customCrop) const result = calculateFitCropViewBox(originalBounds, cropRegion, containerAspect) console.log('[MapRenderer] Calculated displayViewBox:', { customCrop: mapData.customCrop, originalViewBox: mapData.originalViewBox, containerAspect: containerAspect.toFixed(2), result, }) return result }, [mapData.customCrop, mapData.originalViewBox, mapData.viewBox, svgDimensions]) // Parse the display viewBox for animation and calculations const defaultViewBoxParts = useMemo(() => { const parts = displayViewBox.split(' ').map(Number) return { x: parts[0] || 0, y: parts[1] || 0, width: parts[2] || 1000, height: parts[3] || 500, } }, [displayViewBox]) // Compute which regions network cursors are hovering over // Returns a map of regionId -> { playerId, color } for regions with network hovers const networkHoveredRegions = useMemo(() => { const result: Record = {} // Skip if no SVG ref or no cursors if (!svgRef.current) return result Object.entries(otherPlayerCursors).forEach(([playerId, position]) => { // Skip our own cursor and null positions if (playerId === localPlayerId || !position) return // In turn-based mode, only show hover when it's not our turn if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return // Get player color const player = playerMetadata[playerId] if (!player) return // Use SVG's native hit testing // Create an SVGPoint and use getIntersectionList or check each path const svg = svgRef.current if (!svg) return // Find the region element under this point using elementFromPoint // First convert SVG coords to screen coords const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxW = viewBoxParts[2] || 1000 const viewBoxH = viewBoxParts[3] || 500 const svgRect = svg.getBoundingClientRect() const scaleX = svgRect.width / viewBoxW const scaleY = svgRect.height / viewBoxH const screenX = (position.x - viewBoxX) * scaleX + svgRect.left const screenY = (position.y - viewBoxY) * scaleY + svgRect.top // Get element at this screen position const element = document.elementFromPoint(screenX, screenY) if (element && element.hasAttribute('data-region-id')) { const regionId = element.getAttribute('data-region-id') if (regionId) { result[regionId] = { playerId, color: player.color } } } }) return result }, [ otherPlayerCursors, localPlayerId, gameMode, currentPlayer, playerMetadata, displayViewBox, svgDimensions, // Re-run when SVG size changes ]) // State for give-up zoom animation target values const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({ scale: 1, translateX: 0, translateY: 0, }) // Spring for main map zoom animation (used during give-up reveal) // Uses CSS transform for reliable animation instead of viewBox manipulation const mainMapSpring = useSpring({ scale: giveUpZoomTarget.scale, translateX: giveUpZoomTarget.translateX, translateY: giveUpZoomTarget.translateY, config: { tension: 120, friction: 20 }, onChange: (result) => { console.log('[GiveUp Zoom] Spring animating:', result.value) }, }) // Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook // This effect only updates the remaining spring properties: opacity, position, movement multiplier useEffect(() => { magnifierApi.start({ opacity: targetOpacity, top: targetTop, left: targetLeft, movementMultiplier: getMovementMultiplier(smallestRegionSize), }) }, [targetOpacity, targetTop, targetLeft, smallestRegionSize, magnifierApi]) // Check if current target region needs magnification useEffect(() => { if (!currentPrompt || !svgRef.current || !containerRef.current) { setTargetNeedsMagnification(false) return } // Find the path element for the target region const svgElement = svgRef.current const path = svgElement.querySelector(`path[data-region-id="${currentPrompt}"]`) if (!path || !(path instanceof SVGGeometryElement)) { setTargetNeedsMagnification(false) return } // Get the bounding box size const bbox = path.getBoundingClientRect() const pixelWidth = bbox.width const pixelHeight = bbox.height const pixelArea = pixelWidth * pixelHeight // Use same thresholds as region detection const SMALL_REGION_THRESHOLD = 15 // pixels const SMALL_REGION_AREA_THRESHOLD = 200 // px² const isVerySmall = pixelWidth < SMALL_REGION_THRESHOLD || pixelHeight < SMALL_REGION_THRESHOLD || pixelArea < SMALL_REGION_AREA_THRESHOLD setTargetNeedsMagnification(isVerySmall) console.log('[MapRenderer] Target region magnification check:', { regionId: currentPrompt, pixelWidth: pixelWidth.toFixed(2), pixelHeight: pixelHeight.toFixed(2), pixelArea: pixelArea.toFixed(2), isVerySmall, needsMagnification: isVerySmall, }) }, [currentPrompt, svgDimensions]) // Re-check when prompt or SVG size changes // Give up reveal animation effect useEffect(() => { if (!giveUpReveal) { setGiveUpFlashProgress(0) setIsGiveUpAnimating(false) setSavedButtonPosition(null) // Reset transform to default when animation clears setGiveUpZoomTarget({ scale: 1, translateX: 0, translateY: 0 }) return } // Track if this effect has been cleaned up (prevents stale animations) let isCancelled = false let animationFrameId: number | null = null let timeoutId: ReturnType | null = null // Start animation setIsGiveUpAnimating(true) console.log('[GiveUp Zoom] giveUpReveal triggered:', giveUpReveal) // Save current button position before zoom changes the layout if (svgRef.current && containerRef.current) { const svgRect = svgRef.current.getBoundingClientRect() const containerRect = containerRef.current.getBoundingClientRect() const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const buttonTop = svgOffsetY + 8 const buttonRight = containerRect.width - (svgOffsetX + svgRect.width) + 8 setSavedButtonPosition({ top: buttonTop, right: buttonRight }) } // Calculate CSS transform to zoom and center on the revealed region if (svgRef.current && containerRef.current) { const path = svgRef.current.querySelector(`path[data-region-id="${giveUpReveal.regionId}"]`) console.log('[GiveUp Zoom] Found path:', path, 'for regionId:', giveUpReveal.regionId) if (path && path instanceof SVGGeometryElement) { const bbox = path.getBoundingClientRect() const svgRect = svgRef.current.getBoundingClientRect() // Calculate CSS transform for zoom animation // Region center relative to SVG element const regionCenterX = bbox.left + bbox.width / 2 - svgRect.left const regionCenterY = bbox.top + bbox.height / 2 - svgRect.top // SVG center const svgCenterX = svgRect.width / 2 const svgCenterY = svgRect.height / 2 // Calculate scale: zoom in so region is clearly visible // For tiny regions, zoom more; for larger ones, zoom less const regionSize = Math.max(bbox.width, bbox.height) const targetSize = Math.min(svgRect.width, svgRect.height) * 0.3 // Region should be ~30% of viewport const scale = Math.min(8, Math.max(2, targetSize / Math.max(regionSize, 1))) // Calculate translation to center the region // After scaling, we need to translate so the region center is at SVG center const translateX = (svgCenterX - regionCenterX) * scale const translateY = (svgCenterY - regionCenterY) * scale console.log('[GiveUp Zoom] Starting CSS transform animation:', { regionCenter: { x: regionCenterX, y: regionCenterY }, svgCenter: { x: svgCenterX, y: svgCenterY }, regionSize, scale, translate: { x: translateX, y: translateY }, }) // Start zoom-in animation using CSS transform console.log('[GiveUp Zoom] Setting zoom target:', { scale, translateX, translateY }) setGiveUpZoomTarget({ scale, translateX, translateY }) } } // Animation: 3 pulses over 2 seconds const duration = 2000 const pulses = 3 const startTime = Date.now() const animate = () => { // Check if this animation has been cancelled (new give-up started) if (isCancelled) { console.log('[GiveUp Zoom] Animation cancelled - new give-up started') return } const elapsed = Date.now() - startTime const progress = Math.min(elapsed / duration, 1) // Create pulsing effect: sin wave for smooth on/off const pulseProgress = Math.sin(progress * Math.PI * pulses * 2) * 0.5 + 0.5 setGiveUpFlashProgress(pulseProgress) if (progress < 1) { animationFrameId = requestAnimationFrame(animate) } else { // Animation complete - zoom back out to default console.log('[GiveUp Zoom] Zooming back out to default') setGiveUpZoomTarget({ scale: 1, translateX: 0, translateY: 0 }) // Clear reveal state after a short delay to let zoom-out start timeoutId = setTimeout(() => { if (!isCancelled) { setGiveUpFlashProgress(0) setIsGiveUpAnimating(false) setSavedButtonPosition(null) } }, 100) } } animationFrameId = requestAnimationFrame(animate) // Cleanup: cancel animation if giveUpReveal changes before animation completes return () => { isCancelled = true if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId) } if (timeoutId !== null) { clearTimeout(timeoutId) } console.log('[GiveUp Zoom] Cleanup - cancelling previous animation') } }, [giveUpReveal?.timestamp]) // Re-run when timestamp changes // Shift key listener - show magnifier when Shift is held useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift' && !e.repeat) { setShiftPressed(true) } } const handleKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') { setShiftPressed(false) } } window.addEventListener('keydown', handleKeyDown) window.addEventListener('keyup', handleKeyUp) return () => { window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keyup', handleKeyUp) } }, []) const [labelPositions, setLabelPositions] = useState([]) const [smallRegionLabelPositions, setSmallRegionLabelPositions] = useState< Array<{ regionId: string regionName: string isFound: boolean labelX: number // pixel position for label labelY: number // pixel position for label lineStartX: number // pixel position for line start lineStartY: number // pixel position for line start lineEndX: number // pixel position for line end (region center) lineEndY: number // pixel position for line end }> >([]) // Measure container element to get available space for viewBox calculation // IMPORTANT: We measure the container, not the SVG, to avoid circular dependency: // The SVG fills the container, and the viewBox is calculated based on container aspect ratio useEffect(() => { if (!containerRef.current) return const updateDimensions = () => { const rect = containerRef.current?.getBoundingClientRect() if (rect) { setSvgDimensions({ width: rect.width, height: rect.height }) } } // Use ResizeObserver to detect panel resizing (not just window resize) const observer = new ResizeObserver(() => { requestAnimationFrame(() => { updateDimensions() }) }) observer.observe(containerRef.current) // Initial measurement updateDimensions() return () => observer.disconnect() }, []) // No dependencies - container size doesn't depend on viewBox // 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 = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 const svgRect = svgRef.current?.getBoundingClientRect() if (!svgRect) return const scaleX = svgRect.width / viewBoxWidth const scaleY = svgRect.height / viewBoxHeight // Calculate SVG offset within container (accounts for padding) const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top // Collect all regions with their info for force simulation interface LabelNode extends SimulationNodeDatum { id: string name: string x: number y: number targetX: number targetY: number width: number height: number isFound: boolean isSmall: boolean players?: string[] } const allLabelNodes: LabelNode[] = [] // Process both included regions and excluded regions for labeling ;[...mapData.regions, ...excludedRegions].forEach((region) => { // Calculate centroid pixel position directly from SVG coordinates // Account for SVG offset within container (padding, etc.) const centroidPixelX = (region.center[0] - viewBoxX) * scaleX + svgOffsetX const centroidPixelY = (region.center[1] - viewBoxY) * scaleY + svgOffsetY const pixelX = centroidPixelX const pixelY = centroidPixelY // Get the actual region path element to measure its TRUE screen dimensions const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const pixelWidth = pathRect.width const pixelHeight = pathRect.height const pixelArea = pathRect.width * pathRect.height // Check if this is a small region using ACTUAL screen pixels const isSmall = pixelWidth < 10 || pixelHeight < 10 || pixelArea < 100 // Debug logging ONLY for Gibraltar and ultra-tiny regions (< 1px) if (region.id === 'gi' || pixelWidth < 1 || pixelHeight < 1) { console.log( `[MapRenderer] ${region.id === 'gi' ? '🎯 GIBRALTAR' : '🔍 TINY'}: ${region.name} - ` + `W:${pixelWidth.toFixed(2)}px H:${pixelHeight.toFixed(2)}px ` + `Area:${pixelArea.toFixed(2)}px²` ) } // Collect label nodes for regions that need labels // Only show arrow labels for small regions if showArrows flag is enabled // Exception: Washington DC always gets arrow label (too small on USA map) const isDC = region.id === 'dc' const isExcluded = excludedRegionIds.has(region.id) // Show label if: region is found, OR region is excluded (pre-found), OR it's small and arrows enabled const shouldShowLabel = regionsFound.includes(region.id) || isExcluded || (isSmall && (showArrows || isDC)) if (shouldShowLabel) { const players = regionsFound.includes(region.id) ? guessHistory .filter((guess) => guess.regionId === region.id && guess.correct) .map((guess) => guess.playerId) .filter((playerId, index, self) => self.indexOf(playerId) === index) : undefined const labelWidth = region.name.length * 7 + 15 const labelHeight = isSmall ? 25 : 30 // Regular found states (non-small) get positioned exactly at centroid // Only small regions go through force simulation if (isSmall) { allLabelNodes.push({ id: region.id, name: region.name, x: pixelX, // Start directly on region - will spread out to avoid collisions y: pixelY, targetX: pixelX, // Anchor point to pull back toward targetY: pixelY, width: labelWidth, height: labelHeight, isFound: regionsFound.includes(region.id), isSmall, players, }) } else { // Add directly to positions array - no force simulation positions.push({ regionId: region.id, regionName: region.name, x: pixelX, y: pixelY, players: players || [], }) } } }) // Add region obstacles to repel labels away from the map itself interface ObstacleNode extends SimulationNodeDatum { id: string x: number y: number isObstacle: true radius: number } const obstacleNodes: ObstacleNode[] = [] // Add all regions (including unlabeled ones) as obstacles (if enabled) if (useObstacles) { mapData.regions.forEach((region) => { const ghostElement = svgRef.current?.querySelector(`[data-ghost-region="${region.id}"]`) if (!ghostElement) return const ghostRect = ghostElement.getBoundingClientRect() const pixelX = ghostRect.left - containerRect.left + ghostRect.width / 2 const pixelY = ghostRect.top - containerRect.top + ghostRect.height / 2 const regionPath = svgRef.current?.querySelector(`path[data-region-id="${region.id}"]`) if (!regionPath) return const pathRect = regionPath.getBoundingClientRect() const regionRadius = Math.max(pathRect.width, pathRect.height) / 2 obstacleNodes.push({ id: `obstacle-${region.id}`, isObstacle: true, x: pixelX, y: pixelY, radius: regionRadius + obstaclePadding, }) }) } // Combine labels and obstacles for simulation const allNodes = [...allLabelNodes, ...obstacleNodes] // Run force simulation to position labels without overlaps if (allLabelNodes.length > 0) { const simulation = forceSimulation(allNodes) .force( 'collide', forceCollide().radius((d) => { if ('isObstacle' in d && d.isObstacle) { return (d as ObstacleNode).radius } const label = d as LabelNode return Math.max(label.width, label.height) / 2 + collisionPadding }) ) .force( 'x', forceX((d) => { if ('isObstacle' in d && d.isObstacle) return d.x return (d as LabelNode).targetX }).strength(centeringStrength) ) .force( 'y', forceY((d) => { if ('isObstacle' in d && d.isObstacle) return d.y return (d as LabelNode).targetY }).strength(centeringStrength) ) .stop() // Run simulation - labels start on regions and only move as needed for (let i = 0; i < simulationIterations; i++) { simulation.tick() } // Helper: Calculate arrow start point on label edge closest to region const getArrowStartPoint = ( labelX: number, labelY: number, labelWidth: number, labelHeight: number, targetX: number, targetY: number ): { x: number; y: number } => { // Direction from label to region const dx = targetX - labelX const dy = targetY - labelY // Label edges const halfWidth = labelWidth / 2 const halfHeight = labelHeight / 2 // Calculate intersection with label box // Use parametric line equation: point = (labelX, labelY) + t * (dx, dy) // Find t where line intersects rectangle edges let bestT = 0 const epsilon = 1e-10 // Check each edge if (Math.abs(dx) > epsilon) { // Right edge: x = labelX + halfWidth const tRight = halfWidth / dx if (tRight > 0 && tRight <= 1) { const y = labelY + tRight * dy if (Math.abs(y - labelY) <= halfHeight) { bestT = tRight } } // Left edge: x = labelX - halfWidth const tLeft = -halfWidth / dx if (tLeft > 0 && tLeft <= 1) { const y = labelY + tLeft * dy if (Math.abs(y - labelY) <= halfHeight) { if (bestT === 0 || tLeft < bestT) bestT = tLeft } } } if (Math.abs(dy) > epsilon) { // Bottom edge: y = labelY + halfHeight const tBottom = halfHeight / dy if (tBottom > 0 && tBottom <= 1) { const x = labelX + tBottom * dx if (Math.abs(x - labelX) <= halfWidth) { if (bestT === 0 || tBottom < bestT) bestT = tBottom } } // Top edge: y = labelY - halfHeight const tTop = -halfHeight / dy if (tTop > 0 && tTop <= 1) { const x = labelX + tTop * dx if (Math.abs(x - labelX) <= halfWidth) { if (bestT === 0 || tTop < bestT) bestT = tTop } } } return { x: labelX + bestT * dx, y: labelY + bestT * dy, } } // Extract positions from simulation results (only small regions now) for (const node of allLabelNodes) { // Special handling for Washington DC - position off the map to avoid blocking other states if (node.id === 'dc') { // Position DC label to the right of the map, outside the main map area const containerWidth = containerRect.width const labelX = containerWidth - 80 // 80px from right edge const labelY = svgOffsetY + svgRect.height * 0.35 // Upper-middle area const arrowStart = getArrowStartPoint( labelX, labelY, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: labelX, labelY: labelY, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) continue // Skip normal processing } // All remaining nodes are small regions (non-small are added directly to positions) const arrowStart = getArrowStartPoint( node.x!, node.y!, node.width, node.height, node.targetX, node.targetY ) smallPositions.push({ regionId: node.id, regionName: node.name, isFound: node.isFound, labelX: node.x!, labelY: node.y!, lineStartX: arrowStart.x, lineStartY: arrowStart.y, lineEndX: node.targetX, lineEndY: node.targetY, }) } } setLabelPositions(positions) setSmallRegionLabelPositions(smallPositions) // Debug: Log summary console.log('[MapRenderer] Label positions updated:', { mapId: mapData.id, totalRegions: mapData.regions.length, regularLabels: positions.length, smallRegionLabels: smallPositions.length, viewBox: mapData.viewBox, svgDimensions, }) } // Small delay to ensure ghost elements are rendered const timeoutId = setTimeout(updateLabelPositions, 0) return () => { clearTimeout(timeoutId) } }, [ mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox, ]) // Calculate viewBox dimensions for label offset calculations and sea background const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 const showOutline = (region: MapRegion): boolean => { // Easy mode: always show outlines if (difficulty === 'easy') return true // Medium/Hard mode: only show outline on hover or if found return hoveredRegion === region.id || regionsFound.includes(region.id) } // Helper: Get the player who found a specific region const getPlayerWhoFoundRegion = (regionId: string): string | null => { const guess = guessHistory.find((g) => g.regionId === regionId && g.correct) return guess?.playerId || null } // Handle mouse movement to track cursor and show magnifier when needed const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current || !containerRef.current) return // Don't process mouse movement during pointer lock release animation if (isReleasingPointerLock) 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 smoothly animated movement multiplier for gradual cursor dampening transitions // This prevents jarring changes when moving between regions of different sizes const currentMultiplier = magnifierSpring.movementMultiplier.get() // Boundary dampening and squish effect // As cursor approaches edge, dampen movement and visually squish the cursor // When squished enough, the cursor "escapes" through the boundary and releases pointer lock const dampenZone = 40 // Distance from edge where dampening starts (px) const squishZone = 20 // Distance from edge where squish becomes visible (px) const escapeThreshold = 2 // When within this distance, escape! (px) // Calculate SVG offset within container (SVG may be smaller due to aspect ratio) const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top // First, calculate undampened position to check how close we are to edges const undampenedX = lastX + e.movementX * currentMultiplier const undampenedY = lastY + e.movementY * currentMultiplier // Calculate distance from SVG edges (not container edges!) // This is critical - the interactive area is the SVG, not the container const distLeft = undampenedX - svgOffsetX const distRight = svgOffsetX + svgRect.width - undampenedX const distTop = undampenedY - svgOffsetY const distBottom = svgOffsetY + svgRect.height - undampenedY // Find closest edge distance const minDist = Math.min(distLeft, distRight, distTop, distBottom) // Calculate dampening factor based on proximity to edge let dampenFactor = 1.0 if (minDist < dampenZone) { // Quadratic easing for smooth dampening const t = minDist / dampenZone dampenFactor = t * t // Squared for stronger dampening near edge } // Apply dampening to movement - this is the actual cursor position we'll use const dampenedDeltaX = e.movementX * currentMultiplier * dampenFactor const dampenedDeltaY = e.movementY * currentMultiplier * dampenFactor cursorX = lastX + dampenedDeltaX cursorY = lastY + dampenedDeltaY // Now check escape threshold using the DAMPENED position (not undampened!) // This is critical - we need to check where the cursor actually is, not where it would be without dampening // And we must use SVG bounds, not container bounds! const dampenedDistLeft = cursorX - svgOffsetX const dampenedDistRight = svgOffsetX + svgRect.width - cursorX const dampenedDistTop = cursorY - svgOffsetY const dampenedDistBottom = svgOffsetY + svgRect.height - cursorY const dampenedMinDist = Math.min( dampenedDistLeft, dampenedDistRight, dampenedDistTop, dampenedDistBottom ) // Debug logging for boundary proximity if (dampenedMinDist < squishZone) { console.log('[Squish Debug]', { cursorPos: { x: cursorX.toFixed(1), y: cursorY.toFixed(1) }, containerSize: { width: containerRect.width.toFixed(1), height: containerRect.height.toFixed(1), }, svgSize: { width: svgRect.width.toFixed(1), height: svgRect.height.toFixed(1) }, svgOffset: { x: svgOffsetX.toFixed(1), y: svgOffsetY.toFixed(1) }, distances: { left: dampenedDistLeft.toFixed(1), right: dampenedDistRight.toFixed(1), top: dampenedDistTop.toFixed(1), bottom: dampenedDistBottom.toFixed(1), min: dampenedMinDist.toFixed(1), }, dampenFactor: dampenFactor.toFixed(3), thresholds: { squishZone, escapeThreshold, }, willEscape: dampenedMinDist < escapeThreshold, }) } // Check if cursor has squished through and should escape (using dampened position!) if (dampenedMinDist < escapeThreshold && !isReleasingPointerLock) { console.log('[Pointer Lock] 🔓 ESCAPING (squished through boundary):', { dampenedMinDist, escapeThreshold, cursorX, cursorY, whichEdge: { left: dampenedDistLeft === dampenedMinDist, right: dampenedDistRight === dampenedMinDist, top: dampenedDistTop === dampenedMinDist, bottom: dampenedDistBottom === dampenedMinDist, }, }) // Start animation back to initial capture position setIsReleasingPointerLock(true) // Animate cursor back to initial position before releasing if (initialCapturePositionRef.current) { const startPos = { x: cursorX, y: cursorY } const endPos = initialCapturePositionRef.current const duration = 200 // ms const startTime = performance.now() const animate = (currentTime: number) => { const elapsed = currentTime - startTime const progress = Math.min(elapsed / duration, 1) // Ease out cubic for smooth deceleration const eased = 1 - (1 - progress) ** 3 const interpolatedX = startPos.x + (endPos.x - startPos.x) * eased const interpolatedY = startPos.y + (endPos.y - startPos.y) * eased // Update cursor position cursorPositionRef.current = { x: interpolatedX, y: interpolatedY } setCursorPosition({ x: interpolatedX, y: interpolatedY }) if (progress < 1) { requestAnimationFrame(animate) } else { // Animation complete - now release pointer lock console.log('[Pointer Lock] 🔓 Animation complete, releasing pointer lock') document.exitPointerLock() } } requestAnimationFrame(animate) } else { // No initial position saved, release immediately document.exitPointerLock() } // Don't update cursor position in this frame - animation will handle it return } // Calculate squish effect based on proximity to edges (using dampened position!) // Handle horizontal and vertical squishing independently to support corners let squishX = 1.0 let squishY = 1.0 // Horizontal squishing (left/right edges) if (dampenedDistLeft < squishZone) { // Squishing against left edge - compress horizontally const t = 1 - dampenedDistLeft / squishZone squishX = Math.min(squishX, 1.0 - t * 0.5) // Compress to 50% width } else if (dampenedDistRight < squishZone) { // Squishing against right edge - compress horizontally const t = 1 - dampenedDistRight / squishZone squishX = Math.min(squishX, 1.0 - t * 0.5) } // Vertical squishing (top/bottom edges) if (dampenedDistTop < squishZone) { // Squishing against top edge - compress vertically const t = 1 - dampenedDistTop / squishZone squishY = Math.min(squishY, 1.0 - t * 0.5) } else if (dampenedDistBottom < squishZone) { // Squishing against bottom edge - compress vertically const t = 1 - dampenedDistBottom / squishZone squishY = Math.min(squishY, 1.0 - t * 0.5) } // Update squish state setCursorSquish({ x: squishX, y: squishY }) // Clamp to SVG bounds (not container bounds!) // Allow cursor to reach escape threshold at SVG edges cursorX = Math.max(svgOffsetX, Math.min(svgOffsetX + svgRect.width, cursorX)) cursorY = Math.max(svgOffsetY, Math.min(svgOffsetY + svgRect.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 }) // Send cursor position to other players (in SVG coordinates) // In turn-based mode, only broadcast when it's our turn const shouldBroadcastCursor = onCursorUpdate && svgRef.current && (gameMode !== 'turn-based' || currentPlayer === localPlayerId) if (shouldBroadcastCursor) { const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxW = viewBoxParts[2] || 1000 const viewBoxH = viewBoxParts[3] || 500 const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const scaleX = viewBoxW / svgRect.width const scaleY = viewBoxH / svgRect.height const cursorSvgX = (cursorX - svgOffsetX) * scaleX + viewBoxX const cursorSvgY = (cursorY - svgOffsetY) * scaleY + viewBoxY onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }) } // Check if fake cursor is hovering over Give Up button (for pointer lock mode) if (pointerLocked) { const buttonBounds = giveUpButtonBoundsRef.current const isOverButton = buttonBounds && cursorX >= buttonBounds.left && cursorX <= buttonBounds.right && cursorY >= buttonBounds.top && cursorY <= buttonBounds.bottom setIsFakeCursorOverGiveUp(!!isOverButton) } // Use region detection hook to find regions near cursor const detectionResult = detectRegions(cursorX, cursorY) const { detectedRegions: detectedRegionObjects, regionUnderCursor, regionUnderCursorArea, regionsInBox, hasSmallRegion, detectedSmallestSize, totalRegionArea, } = detectionResult if (pointerLocked && detectedRegionObjects.length > 0) { const sortedSizes = detectedRegionObjects.map((r) => `${r.id}: ${r.screenSize.toFixed(2)}px`) console.log('[Zoom Search] Sorted regions (smallest first):', sortedSizes) } // Show magnifier when: // 1. Shift key is held down (manual override) // 2. Current target region needs magnification AND there's a small region nearby const shouldShow = shiftPressed || (targetNeedsMagnification && hasSmallRegion) // Update smallest region size for adaptive cursor dampening // Use hysteresis to prevent rapid flickering at boundaries if (shouldShow && detectedSmallestSize !== Infinity) { // Only update if the new size is significantly different (>20% change) // This prevents jitter when moving near region boundaries const currentSize = smallestRegionSize const sizeRatio = currentSize === Infinity ? 0 : detectedSmallestSize / currentSize const significantChange = currentSize === Infinity || sizeRatio < 0.8 || sizeRatio > 1.25 if (significantChange) { setSmallestRegionSize(detectedSmallestSize) } } else if (smallestRegionSize !== Infinity) { // When leaving precision area, don't immediately jump to Infinity // Instead, set to a large value that will smoothly transition via spring setSmallestRegionSize(100) // Large enough that multiplier becomes 1.0 } // Set hover highlighting based on cursor position // This ensures the crosshairs match what's highlighted if (regionUnderCursor !== hoveredRegion) { setHoveredRegion(regionUnderCursor) } if (shouldShow) { // Filter out found regions from zoom calculations // Found regions shouldn't influence how much we zoom in const unfoundRegionObjects = detectedRegionObjects.filter((r) => !regionsFound.includes(r.id)) // Use adaptive zoom search utility to find optimal zoom const zoomSearchResult = findOptimalZoom({ detectedRegions: unfoundRegionObjects, detectedSmallestSize, cursorX, cursorY, containerRect, svgRect, mapData, svgElement: svgRef.current!, largestPieceSizesCache: largestPieceSizesRef.current, maxZoom: MAX_ZOOM, minZoom: 1, pointerLocked, }) let adaptiveZoom = zoomSearchResult.zoom const boundingBoxes = zoomSearchResult.boundingBoxes // Save bounding boxes for rendering setDebugBoundingBoxes(boundingBoxes) // Save full zoom search result for debug panel setZoomSearchDebugInfo(zoomSearchResult) // Calculate magnifier dimensions (needed for positioning) const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const magnifierHeight = containerRect.height * MAGNIFIER_SIZE_RATIO // 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 } ) } // Store uncapped adaptive zoom before potentially capping it uncappedAdaptiveZoomRef.current = adaptiveZoom // Cap zoom if not in pointer lock mode to prevent excessive screen pixel ratios if (!pointerLocked && containerRef.current && svgRef.current) { const containerRect = containerRef.current.getBoundingClientRect() const svgRect = svgRef.current.getBoundingClientRect() const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] if (viewBoxWidth && !Number.isNaN(viewBoxWidth)) { // Calculate what the screen pixel ratio would be at this zoom const screenPixelRatio = calculateScreenPixelRatio({ magnifierWidth, viewBoxWidth, svgWidth: svgRect.width, zoom: adaptiveZoom, }) // If it exceeds threshold, cap the zoom to stay at threshold if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) { const maxZoom = calculateMaxZoomAtThreshold( PRECISION_MODE_THRESHOLD, magnifierWidth, svgRect.width ) adaptiveZoom = Math.min(adaptiveZoom, maxZoom) console.log( `[Magnifier] Capping zoom at ${adaptiveZoom.toFixed(1)}× (threshold: ${PRECISION_MODE_THRESHOLD} px/px, would have been ${screenPixelRatio.toFixed(1)} px/px)` ) } } } 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 // Notify other players that cursor left // In turn-based mode, only broadcast when it's our turn if (onCursorUpdate && (gameMode !== 'turn-based' || currentPlayer === localPlayerId)) { onCursorUpdate(null) } } return (
`scale(${s}) translate(${tx / s}px, ${ty / s}px)` ), }} > {/* 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 const isBeingRevealed = giveUpReveal?.regionId === region.id // Special styling for excluded regions (grayed out, pre-labeled) // Bright gold flash for give up reveal with high contrast const fill = isBeingRevealed ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` // Brighter gold, higher base opacity : isExcluded ? isDark ? '#374151' // gray-700 : '#d1d5db' // gray-300 : isFound && playerId ? `url(#player-pattern-${playerId})` : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) // During give-up animation, dim all non-revealed regions const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1 // Revealed region gets a prominent stroke // Unfound regions get thicker borders for better visibility against sea const stroke = isBeingRevealed ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` // Orange stroke for contrast : getRegionStroke(isFound, isDark) const strokeWidth = isBeingRevealed ? 3 : isFound ? 1 : 1.5 // Check if a network cursor is hovering over this region const networkHover = networkHoveredRegions[region.id] return ( {/* Glow effect for network-hovered region (other player's cursor) */} {networkHover && !isBeingRevealed && ( )} {/* Glow effect for revealed region */} {isBeingRevealed && ( )} {/* Network hover border (crisp outline in player color) */} {networkHover && !isBeingRevealed && ( )} {/* Region path */} !isExcluded && !pointerLocked && setHoveredRegion(region.id)} onMouseLeave={() => !pointerLocked && setHoveredRegion(null)} onClick={() => { console.log('[CLICK] Region clicked:', { regionId: region.id, regionName: region.name, isExcluded, willCall: !isExcluded, }) if (!isExcluded) { onRegionClick(region.id, region.name) } }} // Disable clicks on excluded regions style={{ cursor: isExcluded ? 'default' : 'pointer', transition: 'all 0.2s ease', }} /> {/* Ghost element for region center position tracking */} ) })} {/* Debug: Render bounding boxes (only if enabled) */} {showDebugBoundingBoxes && debugBoundingBoxes.map((bbox) => { // Color based on acceptance and importance // Green = accepted, Orange = high importance, Yellow = medium, Gray = low const importance = bbox.importance ?? 0 let strokeColor = '#888888' // Default gray for low importance let fillColor = 'rgba(136, 136, 136, 0.1)' if (bbox.wasAccepted) { strokeColor = '#00ff00' // Green for accepted region fillColor = 'rgba(0, 255, 0, 0.15)' } else if (importance > 1.5) { strokeColor = '#ff6600' // Orange for high importance (2.0× boost + close) fillColor = 'rgba(255, 102, 0, 0.1)' } else if (importance > 0.5) { strokeColor = '#ffcc00' // Yellow for medium importance fillColor = 'rgba(255, 204, 0, 0.1)' } return ( ) })} {/* Arrow marker definition */} {/* Player emoji patterns for region backgrounds */} {Object.values(playerMetadata).map((player) => ( {player.emoji} ))} {/* Magnifier region indicator on main map */} {showMagnifier && cursorPosition && svgRef.current && containerRef.current && ( { const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() const viewBoxParts = displayViewBox.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={zoomSpring.to((zoom: number) => { const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() const viewBoxParts = displayViewBox.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={zoomSpring.to((zoom: number) => { const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] || 1000 return viewBoxWidth / zoom })} height={zoomSpring.to((zoom: number) => { const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxHeight = viewBoxParts[3] || 1000 return viewBoxHeight / zoom })} fill="none" stroke={isDark ? '#60a5fa' : '#3b82f6'} strokeWidth={viewBoxWidth / 500} vectorEffect="non-scaling-stroke" strokeDasharray="5,5" pointerEvents="none" opacity={0.8} /> )} {/* HTML labels positioned absolutely over the SVG */} {labelPositions.map((label) => { const labelOpacity = calculateLabelOpacity( label.x, label.y, label.regionId, cursorPosition, hoveredRegion, regionsFound, isGiveUpAnimating ) return (
{/* Region name */}
{label.regionName}
{/* Player avatars */} {label.players.length > 0 && (
{label.players.map((playerId) => { const player = playerMetadata[playerId] if (!player) return null return (
{player.emoji}
) })}
)}
) })} {/* Small region labels with arrows positioned absolutely over the SVG */} {smallRegionLabelPositions.map((label) => { const labelOpacity = calculateLabelOpacity( label.labelX, label.labelY, label.regionId, cursorPosition, hoveredRegion, regionsFound, isGiveUpAnimating ) return (
{/* Arrow line - use SVG positioned absolutely */} {/* Debug: Show arrow endpoint (region centroid) */} {/* Label box and text */}
onRegionClick(label.regionId, label.regionName)} onMouseEnter={() => setHoveredRegion(label.regionId)} onMouseLeave={() => setHoveredRegion(null)} > {/* Background box */}
{label.regionName}
) })} {/* Debug: Bounding box labels as HTML overlays */} {showDebugBoundingBoxes && containerRef.current && svgRef.current && debugBoundingBoxes.map((bbox) => { const importance = bbox.importance ?? 0 let strokeColor = '#888888' if (bbox.wasAccepted) { strokeColor = '#00ff00' } else if (importance > 1.5) { strokeColor = '#ff6600' } else if (importance > 0.5) { strokeColor = '#ffcc00' } // Convert SVG coordinates to pixel coordinates const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() const viewBoxParts = displayViewBox.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 = svgRect.width / viewBoxWidth const scaleY = svgRect.height / viewBoxHeight const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top // Convert bbox center from SVG coords to pixels const centerX = (bbox.x + bbox.width / 2 - viewBoxX) * scaleX + svgOffsetX const centerY = (bbox.y + bbox.height / 2 - viewBoxY) * scaleY + svgOffsetY return (
{bbox.regionId}
{importance.toFixed(2)}
) })} {/* Custom Cursor - Visible when pointer lock is active */} {(() => { // Debug logging removed - was flooding console return pointerLocked && cursorPosition ? (
{/* Crosshair - Vertical line */}
{/* Crosshair - Horizontal line */}
) : null })()} {/* Magnifier overlay - centers on cursor position */} {(() => { if (!cursorPosition || !svgRef.current || !containerRef.current) { return null } return ( 60x) gets gold border, normal zoom gets blue border border: zoomSpring.to( (zoom: number) => 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: zoomSpring.to((zoom: number) => zoom > HIGH_ZOOM_THRESHOLD ? '0 10px 40px rgba(251, 191, 36, 0.4), 0 0 20px rgba(251, 191, 36, 0.2)' // Gold glow : '0 10px 40px rgba(0, 0, 0, 0.5)' ), background: isDark ? '#111827' : '#f3f4f6', opacity: magnifierSpring.opacity, }} > { // Calculate magnified viewBox centered on cursor const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() // Convert cursor position to SVG coordinates const viewBoxParts = displayViewBox.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 // Center position relative to SVG (uses reveal center during give-up animation) 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%', filter: (() => { // Apply "disabled" visual effect when at threshold but not in precision mode if (pointerLocked) return 'none' const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect) return 'none' const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return 'none' const currentZoom = getCurrentZoom() const screenPixelRatio = calculateScreenPixelRatio({ magnifierWidth, viewBoxWidth, svgWidth: svgRect.width, zoom: currentZoom, }) // When at or above threshold (but not in precision mode), add disabled effect if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) { return 'brightness(0.6) saturate(0.5)' } return 'none' })(), }} > {/* Sea/ocean background for magnifier - solid color to match container */} {(() => { const viewBoxParts = displayViewBox.split(' ').map(Number) return ( ) })()} {/* Render all regions in magnified view */} {mapData.regions.map((region) => { const isFound = regionsFound.includes(region.id) const playerId = isFound ? getPlayerWhoFoundRegion(region.id) : null const isBeingRevealed = giveUpReveal?.regionId === region.id // Bright gold flash for give up reveal in magnifier too const fill = isBeingRevealed ? `rgba(255, 200, 0, ${0.6 + giveUpFlashProgress * 0.4})` : isFound && playerId ? `url(#player-pattern-${playerId})` : getRegionColor(region.id, isFound, hoveredRegion === region.id, isDark) // During give-up animation, dim all non-revealed regions const dimmedOpacity = isGiveUpAnimating && !isBeingRevealed ? 0.25 : 1 // Revealed region gets a prominent stroke // Unfound regions get thicker borders for better visibility against sea const stroke = isBeingRevealed ? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` : getRegionStroke(isFound, isDark) const strokeWidth = isBeingRevealed ? 2 : isFound ? 0.5 : 1 return ( {/* Glow effect for revealed region in magnifier */} {isBeingRevealed && ( )} ) })} {/* Crosshair at center position (cursor or reveal center during animation) */} {(() => { const containerRect = containerRef.current!.getBoundingClientRect() const svgRect = svgRef.current!.getBoundingClientRect() const viewBoxParts = displayViewBox.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 ( <> ) })()} {/* Pixel grid overlay - shows when approaching/at/above precision mode threshold */} {(() => { const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect) return null const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] const viewBoxHeight = viewBoxParts[3] const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return null const currentZoom = getCurrentZoom() const screenPixelRatio = calculateScreenPixelRatio({ magnifierWidth, viewBoxWidth, svgWidth: svgRect.width, zoom: currentZoom, }) // Fade grid in/out within 30% range on both sides of threshold // Visible from 70% to 130% of threshold (14 to 26 px/px at threshold=20) const fadeStartRatio = PRECISION_MODE_THRESHOLD * 0.7 const fadeEndRatio = PRECISION_MODE_THRESHOLD * 1.3 if (screenPixelRatio < fadeStartRatio || screenPixelRatio > fadeEndRatio) return null // Calculate opacity: 0 at edges (70% and 130%), 1 at threshold (100%) let gridOpacity: number if (screenPixelRatio <= PRECISION_MODE_THRESHOLD) { // Fading in: 0 at 70%, 1 at 100% gridOpacity = (screenPixelRatio - fadeStartRatio) / (PRECISION_MODE_THRESHOLD - fadeStartRatio) } else { // Fading out: 1 at 100%, 0 at 130% gridOpacity = (fadeEndRatio - screenPixelRatio) / (fadeEndRatio - PRECISION_MODE_THRESHOLD) } // Calculate grid spacing in SVG units // Each grid cell represents one screen pixel of mouse movement on the main map const mainMapSvgUnitsPerScreenPixel = viewBoxWidth / svgRect.width const gridSpacingSvgUnits = mainMapSvgUnitsPerScreenPixel // Calculate magnified viewport dimensions for grid bounds const magnifiedViewBoxWidth = viewBoxWidth / currentZoom // Get center position in SVG coordinates (uses reveal center during give-up animation) 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 // Calculate grid bounds (magnifier viewport) const magnifiedHeight = viewBoxHeight / currentZoom const gridLeft = cursorSvgX - magnifiedViewBoxWidth / 2 const gridRight = cursorSvgX + magnifiedViewBoxWidth / 2 const gridTop = cursorSvgY - magnifiedHeight / 2 const gridBottom = cursorSvgY + magnifiedHeight / 2 // Calculate grid line positions aligned with cursor (crosshair center) const lines: Array<{ type: 'h' | 'v'; pos: number }> = [] // Vertical lines (aligned with cursor X) const firstVerticalLine = Math.floor((gridLeft - cursorSvgX) / gridSpacingSvgUnits) * gridSpacingSvgUnits + cursorSvgX for (let x = firstVerticalLine; x <= gridRight; x += gridSpacingSvgUnits) { lines.push({ type: 'v', pos: x }) } // Horizontal lines (aligned with cursor Y) const firstHorizontalLine = Math.floor((gridTop - cursorSvgY) / gridSpacingSvgUnits) * gridSpacingSvgUnits + cursorSvgY for (let y = firstHorizontalLine; y <= gridBottom; y += gridSpacingSvgUnits) { lines.push({ type: 'h', pos: y }) } // Apply opacity to grid color const baseOpacity = isDark ? 0.5 : 0.6 const finalOpacity = baseOpacity * gridOpacity const gridColor = `rgba(251, 191, 36, ${finalOpacity})` return ( {lines.map((line, i) => line.type === 'v' ? ( ) : ( ) )} ) })()} {/* Debug: Bounding boxes for detected regions in magnifier */} {SHOW_DEBUG_BOUNDING_BOXES && debugBoundingBoxes.map((bbox) => { const importance = bbox.importance ?? 0 // Color-code by importance let strokeColor = '#888888' // Gray for low importance if (bbox.wasAccepted) { strokeColor = '#00ff00' // Green for accepted region } else if (importance > 1.5) { strokeColor = '#ff6600' // Orange for high importance } else if (importance > 0.5) { strokeColor = '#ffcc00' // Yellow for medium importance } return ( ) })} {/* Debug: Bounding box labels as HTML overlays - positioned using animated values */} {SHOW_DEBUG_BOUNDING_BOXES && debugBoundingBoxes.map((bbox) => { const importance = bbox.importance ?? 0 let strokeColor = '#888888' if (bbox.wasAccepted) { strokeColor = '#00ff00' } else if (importance > 1.5) { strokeColor = '#ff6600' } else if (importance > 0.5) { strokeColor = '#ffcc00' } // Parse viewBox - these are stable values from mapData const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 // Calculate bbox center in SVG coordinates const bboxCenterSvgX = bbox.x + bbox.width / 2 const bboxCenterSvgY = bbox.y + bbox.height / 2 // Use animated interpolation to sync with magnifier viewBox // ALL measurements must be taken inside the callback to stay in sync return ( { const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect || !cursorPosition) return '-9999px' // Magnifier dimensions const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO // Convert cursor to SVG coordinates (same as magnifier viewBox calc) const scaleX = viewBoxWidth / svgRect.width const svgOffsetX = svgRect.left - containerRect.left const cursorSvgX = (cursorPosition.x - svgOffsetX) * scaleX + viewBoxX // Magnified viewport in SVG coordinates const magnifiedWidth = viewBoxWidth / zoom const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2 // Position of bbox center relative to magnified viewport (0-1) const relativeX = (bboxCenterSvgX - magnifiedViewBoxX) / magnifiedWidth if (relativeX < 0 || relativeX > 1) return '-9999px' return `${relativeX * magnifierWidth}px` }), top: zoomSpring.to((zoom: number) => { const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect || !cursorPosition) return '-9999px' // Magnifier dimensions (2:1 aspect ratio) const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const magnifierHeight = containerRect.height * MAGNIFIER_SIZE_RATIO // Convert cursor to SVG coordinates (same as magnifier viewBox calc) const scaleY = viewBoxHeight / svgRect.height const svgOffsetY = svgRect.top - containerRect.top const cursorSvgY = (cursorPosition.y - svgOffsetY) * scaleY + viewBoxY // Magnified viewport in SVG coordinates const magnifiedHeight = viewBoxHeight / zoom const magnifiedViewBoxY = cursorSvgY - magnifiedHeight / 2 // Position of bbox center relative to magnified viewport (0-1) const relativeY = (bboxCenterSvgY - magnifiedViewBoxY) / magnifiedHeight if (relativeY < 0 || relativeY > 1) return '-9999px' return `${relativeY * magnifierHeight}px` }), transform: 'translate(-50%, -50%)', pointerEvents: 'none', zIndex: 15, fontSize: '10px', fontWeight: 'bold', color: strokeColor, textAlign: 'center', textShadow: '0 0 2px black, 0 0 2px black, 0 0 2px black', whiteSpace: 'nowrap', }} >
{bbox.regionId}
{importance.toFixed(2)}
) })} {/* Magnifier label */} { // Request pointer lock when user clicks on notice if (!pointerLocked && containerRef.current) { e.stopPropagation() // Prevent click from bubbling to map containerRef.current.requestPointerLock() } }} data-element="magnifier-label" > {zoomSpring.to((z: number) => { const multiplier = magnifierSpring.movementMultiplier.get() // When in pointer lock mode, show "Precision mode active" notice if (pointerLocked) { return 'Precision mode active' } // When NOT in pointer lock, calculate screen pixel ratio const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect) { return `${z.toFixed(1)}×` } const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) { return `${z.toFixed(1)}×` } const screenPixelRatio = calculateScreenPixelRatio({ magnifierWidth, viewBoxWidth, svgWidth: svgRect.width, zoom: z, }) // If at or above threshold, show notice about activating precision controls if (isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) { return 'Click to activate precision mode' } // Below threshold - show debug info in dev, simple zoom in prod if (SHOW_MAGNIFIER_DEBUG_INFO) { return `${z.toFixed(1)}× | ${screenPixelRatio.toFixed(1)} px/px` } return `${z.toFixed(1)}×` })} {/* Scrim overlay - shows when at threshold to indicate barrier */} {!pointerLocked && (() => { const containerRect = containerRef.current?.getBoundingClientRect() const svgRect = svgRef.current?.getBoundingClientRect() if (!containerRect || !svgRect) return null const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxWidth = viewBoxParts[2] if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return null const currentZoom = getCurrentZoom() const screenPixelRatio = calculateScreenPixelRatio({ magnifierWidth, viewBoxWidth, svgWidth: svgRect.width, zoom: currentZoom, }) // Only show scrim when at or above threshold if (!isAboveThreshold(screenPixelRatio, PRECISION_MODE_THRESHOLD)) return null return (
) })()} ) })()} {/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */} {(() => { if (!showMagnifier || !cursorPosition || !svgRef.current || !containerRef.current) { return null } const containerRect = containerRef.current.getBoundingClientRect() const svgRect = svgRef.current.getBoundingClientRect() // Get magnifier dimensions and position const magnifierWidth = containerRect.width * MAGNIFIER_SIZE_RATIO const magnifierHeight = containerRect.height * MAGNIFIER_SIZE_RATIO // aspectRatio 2/1 // Magnifier position (animated via spring, but we use target for calculation) const magTop = targetTop const magLeft = targetLeft // Calculate indicator box position in screen coordinates const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxWidth = viewBoxParts[2] || 1000 const viewBoxHeight = viewBoxParts[3] || 1000 const currentZoom = getCurrentZoom() const indicatorWidth = viewBoxWidth / currentZoom const indicatorHeight = viewBoxHeight / currentZoom // Convert cursor to SVG coordinates 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 // Indicator box in SVG coordinates const indSvgLeft = cursorSvgX - indicatorWidth / 2 const indSvgTop = cursorSvgY - indicatorHeight / 2 const indSvgRight = indSvgLeft + indicatorWidth const indSvgBottom = indSvgTop + indicatorHeight // Convert indicator corners to screen coordinates const svgToScreen = (svgX: number, svgY: number) => ({ x: (svgX - viewBoxX) / scaleX + svgOffsetX, y: (svgY - viewBoxY) / scaleY + svgOffsetY, }) const indTL = svgToScreen(indSvgLeft, indSvgTop) const indTR = svgToScreen(indSvgRight, indSvgTop) const indBL = svgToScreen(indSvgLeft, indSvgBottom) const indBR = svgToScreen(indSvgRight, indSvgBottom) // Magnifier corners in screen coordinates const magTL = { x: magLeft, y: magTop } const magTR = { x: magLeft + magnifierWidth, y: magTop } const magBL = { x: magLeft, y: magTop + magnifierHeight } const magBR = { x: magLeft + magnifierWidth, y: magTop + magnifierHeight } // Check if a line segment passes through a rectangle (excluding endpoints) const linePassesThroughRect = ( from: { x: number; y: number }, to: { x: number; y: number }, rectLeft: number, rectTop: number, rectRight: number, rectBottom: number ) => { // Sample points along the line (excluding endpoints) for (let t = 0.1; t <= 0.9; t += 0.1) { const px = from.x + (to.x - from.x) * t const py = from.y + (to.y - from.y) * t if (px > rectLeft && px < rectRight && py > rectTop && py < rectBottom) { return true } } return false } // Create bezier paths with elegant curves const createBezierPath = (from: { x: number; y: number }, to: { x: number; y: number }) => { const dx = to.x - from.x const dy = to.y - from.y const dist = Math.sqrt(dx * dx + dy * dy) // Perpendicular offset creates gentle outward bow const bowAmount = dist * 0.06 const perpX = (-dy / dist) * bowAmount const perpY = (dx / dist) * bowAmount const midX = (from.x + to.x) / 2 + perpX const midY = (from.y + to.y) / 2 + perpY // Quadratic bezier for smooth curve return `M ${from.x} ${from.y} Q ${midX} ${midY}, ${to.x} ${to.y}` } // Define the corner pairs with identifiers const cornerPairs = [ { from: indTL, to: magTL, corner: indTL }, { from: indTR, to: magTR, corner: indTR }, { from: indBL, to: magBL, corner: indBL }, { from: indBR, to: magBR, corner: indBR }, ] // Filter out lines that pass through either rectangle const visibleCornerPairs = cornerPairs.filter(({ from, to }) => { // Check if line passes through magnifier const passesThroughMag = linePassesThroughRect( from, to, magLeft, magTop, magLeft + magnifierWidth, magTop + magnifierHeight ) // Check if line passes through indicator const passesThroughInd = linePassesThroughRect( from, to, indTL.x, indTL.y, indBR.x, indBR.y ) return !passesThroughMag && !passesThroughInd }) const paths = visibleCornerPairs.map(({ from, to }) => createBezierPath(from, to)) const visibleCorners = visibleCornerPairs.map(({ corner }) => corner) // Color based on zoom level (matches magnifier border) const isHighZoom = currentZoom > HIGH_ZOOM_THRESHOLD const lineColor = isHighZoom ? isDark ? '#fbbf24' : '#f59e0b' // gold : isDark ? '#60a5fa' : '#3b82f6' // blue const glowColor = isHighZoom ? 'rgba(251, 191, 36, 0.6)' : 'rgba(96, 165, 250, 0.6)' return ( {/* Gradient for lines - fades toward magnifier */} {/* Glow filter for premium effect */} {/* Animated dash pattern */} {/* Glow layer (underneath) */} {paths.map((d, i) => ( ))} {/* Main lines with gradient */} {paths.map((d, i) => ( ))} {/* Corner dots on indicator for visible lines only */} {visibleCorners.map((corner, i) => ( ))} ) })()} {/* Debug: Auto zoom detection visualization (dev only) */} {SHOW_MAGNIFIER_DEBUG_INFO && cursorPosition && containerRef.current && ( <> {/* Detection box - 50px box around cursor */}
{/* Detection info overlay - top left corner */} {(() => { const { detectedRegions, hasSmallRegion, detectedSmallestSize } = detectRegions( cursorPosition.x, cursorPosition.y ) return (
Detection Box (50px)
Regions detected: {detectedRegions.length}
Has small region: {hasSmallRegion ? 'YES' : 'NO'}
Smallest size:{' '} {detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
{/* Zoom Decision Details */} {zoomSearchDebugInfo && ( <>
Zoom Decision:
Final zoom: {zoomSearchDebugInfo.zoom.toFixed(1)}× {!zoomSearchDebugInfo.foundGoodZoom && ' (fallback to min)'}
Accepted: {zoomSearchDebugInfo.acceptedRegionId || 'none'}
Thresholds: {(zoomSearchDebugInfo.acceptanceThresholds.min * 100).toFixed(0)}% - {(zoomSearchDebugInfo.acceptanceThresholds.max * 100).toFixed(0)}% of magnifier
Region Analysis (top 3):
{Array.from( new Map( zoomSearchDebugInfo.regionDecisions.map((d) => [d.regionId, d]) ).values() ) .sort((a, b) => b.importance - a.importance) .slice(0, 3) .map((decision) => { const marker = decision.wasAccepted ? '✓' : '✗' const color = decision.wasAccepted ? '#0f0' : '#888' return (
{marker} {decision.regionId}: {decision.currentSize.width.toFixed(0)}× {decision.currentSize.height.toFixed(0)}px {decision.rejectionReason && ` (${decision.rejectionReason})`}
) })} )}
Detected Regions ({detectedRegions.length}):
{detectedRegions.map((region) => (
• {region.id}: {region.pixelWidth.toFixed(1)}×{region.pixelHeight.toFixed(1)}px {region.isVerySmall ? ' (SMALL)' : ''}
))}
Current Zoom: {getCurrentZoom().toFixed(1)}×
Target Zoom: {targetZoom.toFixed(1)}×
) })()} )} {/* Other players' cursors - show in multiplayer when not exclusively our turn */} {svgRef.current && containerRef.current && Object.entries(otherPlayerCursors).map(([playerId, position]) => { // Skip our own cursor and null positions if (playerId === localPlayerId || !position) return null // In turn-based mode, only show other cursors when it's not our turn if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return null // Get player metadata for emoji and color const player = playerMetadata[playerId] if (!player) return null // In collaborative mode, find all players from the same session and show all their emojis // Use memberPlayers (from roomData) which is the canonical source of player ownership const cursorUserId = position.userId const sessionPlayers = gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId] ? memberPlayers[cursorUserId] : [player] // Convert SVG coordinates to screen coordinates const svgRect = svgRef.current!.getBoundingClientRect() const containerRect = containerRef.current!.getBoundingClientRect() const viewBoxParts = displayViewBox.split(' ').map(Number) const viewBoxX = viewBoxParts[0] || 0 const viewBoxY = viewBoxParts[1] || 0 const viewBoxW = viewBoxParts[2] || 1000 const viewBoxH = viewBoxParts[3] || 500 const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top const scaleX = svgRect.width / viewBoxW const scaleY = svgRect.height / viewBoxH const screenX = (position.x - viewBoxX) * scaleX + svgOffsetX const screenY = (position.y - viewBoxY) * scaleY + svgOffsetY // Check if cursor is within SVG bounds if ( screenX < svgOffsetX || screenX > svgOffsetX + svgRect.width || screenY < svgOffsetY || screenY > svgOffsetY + svgRect.height ) { return null } return (
{/* Crosshair - centered on the cursor position */} {/* Outer ring */} {/* Cross lines */} {/* Center dot */} {/* Player emoji label(s) - positioned below crosshair */} {/* In collaborative mode, show all emojis from the same session */}
{sessionPlayers.map((p) => p.emoji).join('')}
) })} {/* Give Up button overlay - positioned within SVG bounds for pointer lock accessibility */} {(() => { // Use svgDimensions to trigger re-render on resize, but get actual rect for positioning if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null // During give-up animation, use saved position to prevent jumping due to SVG scale transform let buttonTop: number let buttonRight: number if (isGiveUpAnimating && savedButtonPosition) { // Use saved position during animation buttonTop = savedButtonPosition.top buttonRight = savedButtonPosition.right } else { // Calculate position normally const svgRect = svgRef.current.getBoundingClientRect() const containerRect = containerRef.current.getBoundingClientRect() // Position relative to SVG, not container (cursor is clamped to SVG bounds during pointer lock) const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top // Position in top-right corner of SVG with some padding buttonTop = svgOffsetY + 8 buttonRight = containerRect.width - (svgOffsetX + svgRect.width) + 8 } return ( ) })()} {/* Show waiting message for give up voting (cooperative multiplayer with multiple sessions) */} {gameMode === 'cooperative' && activeUserIds.length > 1 && giveUpVotes.length > 0 && giveUpVotes.length < activeUserIds.length && viewerId && giveUpVotes.includes(viewerId) && (() => { if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null const svgRect = svgRef.current.getBoundingClientRect() const containerRect = containerRef.current.getBoundingClientRect() const svgOffsetY = svgRect.top - containerRect.top const buttonRight = containerRect.width - (svgRect.left - containerRect.left + svgRect.width) + 8 const remaining = activeUserIds.length - giveUpVotes.length return (
Waiting for {remaining} other {remaining === 1 ? 'player' : 'players'}...
) })()} {/* Dev-only crop tool for getting custom viewBox coordinates */}
) }