4805 lines
188 KiB
TypeScript
4805 lines
188 KiB
TypeScript
'use client'
|
||
|
||
import { animated, to, useSpring } from '@react-spring/web'
|
||
import { css } from '@styled/css'
|
||
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useTheme } from '@/contexts/ThemeContext'
|
||
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
|
||
import type { ContinentId } from '../continents'
|
||
import { useHotColdFeedback } from '../hooks/useHotColdFeedback'
|
||
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
|
||
import { usePointerLock } from '../hooks/usePointerLock'
|
||
import { useRegionDetection } from '../hooks/useRegionDetection'
|
||
import { useHasRegionHint, useRegionHint } from '../hooks/useRegionHint'
|
||
import { useSpeakHint } from '../hooks/useSpeakHint'
|
||
import {
|
||
getLabelTextColor,
|
||
getLabelTextShadow,
|
||
getRegionColor,
|
||
getRegionStroke,
|
||
} from '../mapColors'
|
||
import {
|
||
ASSISTANCE_LEVELS,
|
||
calculateFitCropViewBox,
|
||
calculateSafeZoneViewBox,
|
||
filterRegionsByContinent,
|
||
getCountryFlagEmoji,
|
||
parseViewBox,
|
||
type SafeZoneMargins,
|
||
USA_MAP,
|
||
WORLD_MAP,
|
||
} from '../maps'
|
||
import type { HintMap } from '../messages'
|
||
import { useKnowYourWorld } from '../Provider'
|
||
import type { MapData, MapRegion } from '../types'
|
||
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
|
||
import type { FeedbackType } from '../utils/hotColdPhrases'
|
||
import {
|
||
getAdjustedMagnifiedDimensions,
|
||
getMagnifierDimensions,
|
||
} from '../utils/magnifierDimensions'
|
||
import {
|
||
calculateMaxZoomAtThreshold,
|
||
calculateScreenPixelRatio,
|
||
isAboveThreshold,
|
||
} from '../utils/screenPixelRatio'
|
||
import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration'
|
||
import { CelebrationOverlay } from './CelebrationOverlay'
|
||
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'
|
||
|
||
// Debug flag: show safe zone rectangles (leftover area and crop region) - dev only
|
||
const SHOW_SAFE_ZONE_DEBUG = 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
|
||
|
||
// Game nav height offset - buttons should appear below the nav when in full-viewport mode
|
||
const NAV_HEIGHT_OFFSET = 150
|
||
|
||
// Safe zone margins - areas reserved for floating UI elements (in pixels)
|
||
// These define where the crop region should NOT appear, ensuring findable regions
|
||
// are visible and not obscured by UI controls
|
||
const SAFE_ZONE_MARGINS: SafeZoneMargins = {
|
||
top: 290, // Space for nav (~150px) + floating prompt (~140px with name input + controls row)
|
||
right: 0, // Controls now in floating prompt, no right margin needed
|
||
bottom: 0, // Error banner can overlap map
|
||
left: 0, // Progress at top-left is small, doesn't need full-height margin
|
||
}
|
||
|
||
/**
|
||
* Get emoji for hot/cold feedback type
|
||
* Returns emoji that matches the temperature/status of the last feedback
|
||
*/
|
||
function getHotColdEmoji(feedbackType: FeedbackType | null): string {
|
||
switch (feedbackType) {
|
||
case 'found_it':
|
||
return '🎯'
|
||
case 'on_fire':
|
||
return '🔥'
|
||
case 'hot':
|
||
return '🥵'
|
||
case 'warmer':
|
||
return '☀️'
|
||
case 'colder':
|
||
return '🌧️'
|
||
case 'cold':
|
||
return '🥶'
|
||
case 'freezing':
|
||
return '❄️'
|
||
case 'overshot':
|
||
return '↩️'
|
||
case 'stuck':
|
||
return '🤔'
|
||
default:
|
||
return '🌡️' // Default thermometer when no feedback yet
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calculate the actual rendered viewport within an SVG element.
|
||
* SVG uses preserveAspectRatio="xMidYMid meet" by default, which:
|
||
* - Scales uniformly to fit within the element while preserving aspect ratio
|
||
* - Centers the content, creating letterboxing if aspect ratios don't match
|
||
*
|
||
* Returns the rendered dimensions, offset from SVG element origin, and scale factors.
|
||
*/
|
||
function getRenderedViewport(
|
||
svgRect: DOMRect,
|
||
viewBoxX: number,
|
||
viewBoxY: number,
|
||
viewBoxWidth: number,
|
||
viewBoxHeight: number
|
||
) {
|
||
const svgAspect = svgRect.width / svgRect.height
|
||
const viewBoxAspect = viewBoxWidth / viewBoxHeight
|
||
|
||
let renderedWidth: number
|
||
let renderedHeight: number
|
||
let letterboxX: number
|
||
let letterboxY: number
|
||
|
||
if (svgAspect > viewBoxAspect) {
|
||
// SVG element is wider than viewBox - letterboxing on sides
|
||
renderedHeight = svgRect.height
|
||
renderedWidth = renderedHeight * viewBoxAspect
|
||
letterboxX = (svgRect.width - renderedWidth) / 2
|
||
letterboxY = 0
|
||
} else {
|
||
// SVG element is taller than viewBox - letterboxing on top/bottom
|
||
renderedWidth = svgRect.width
|
||
renderedHeight = renderedWidth / viewBoxAspect
|
||
letterboxX = 0
|
||
letterboxY = (svgRect.height - renderedHeight) / 2
|
||
}
|
||
|
||
// Scale factor is uniform (same for X and Y due to preserveAspectRatio)
|
||
const scale = renderedWidth / viewBoxWidth
|
||
|
||
return {
|
||
renderedWidth,
|
||
renderedHeight,
|
||
letterboxX, // Offset from SVG element left edge to rendered content
|
||
letterboxY, // Offset from SVG element top edge to rendered content
|
||
scale, // Pixels per viewBox unit
|
||
viewBoxX,
|
||
viewBoxY,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
assistanceLevel: 'learning' | 'guided' | 'helpful' | 'standard' | 'none' // Controls gameplay features (hints, hot/cold)
|
||
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
|
||
// Hint highlight animation
|
||
hintActive: {
|
||
regionId: 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<
|
||
string,
|
||
{
|
||
x: number
|
||
y: number
|
||
userId: string
|
||
hoveredRegionId: string | null
|
||
} | null
|
||
>
|
||
onCursorUpdate?: (
|
||
cursorPosition: { x: number; y: number } | null,
|
||
hoveredRegionId: string | 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<string, Array<{ id: string; name: string; emoji: string; color: string }>>
|
||
/** When true, hints are locked (e.g., user hasn't typed required name confirmation yet) */
|
||
hintsLocked?: boolean
|
||
/** When true, fill the parent container with position: absolute */
|
||
fillContainer?: boolean
|
||
/** Current difficulty level for display (deprecated - use assistanceLevel) */
|
||
difficulty?: string
|
||
/** Map display name */
|
||
mapName?: string
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
assistanceLevel,
|
||
selectedMap,
|
||
selectedContinent,
|
||
onRegionClick,
|
||
guessHistory,
|
||
playerMetadata,
|
||
giveUpReveal,
|
||
hintActive,
|
||
onGiveUp,
|
||
forceTuning = {},
|
||
showDebugBoundingBoxes = SHOW_DEBUG_BOUNDING_BOXES,
|
||
gameMode,
|
||
currentPlayer,
|
||
localPlayerId,
|
||
otherPlayerCursors = {},
|
||
onCursorUpdate,
|
||
giveUpVotes = [],
|
||
activeUserIds = [],
|
||
viewerId,
|
||
memberPlayers = {},
|
||
hintsLocked = false,
|
||
fillContainer = false,
|
||
difficulty,
|
||
mapName,
|
||
}: MapRendererProps) {
|
||
// Get context for sharing state with GameInfoPanel
|
||
const {
|
||
setControlsState,
|
||
sharedContainerRef,
|
||
isInTakeover,
|
||
celebration,
|
||
setCelebration,
|
||
promptStartTime,
|
||
} = useKnowYourWorld()
|
||
// 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'
|
||
|
||
// Visual debug mode from global context (only enabled in dev AND when user toggles it on)
|
||
const { isVisualDebugEnabled } = useVisualDebugSafe()
|
||
|
||
// Effective debug flags - combine prop with context
|
||
// Props allow component-level override, context allows global toggle
|
||
const effectiveShowDebugBoundingBoxes = showDebugBoundingBoxes && isVisualDebugEnabled
|
||
const effectiveShowMagnifierDebugInfo = SHOW_MAGNIFIER_DEBUG_INFO && isVisualDebugEnabled
|
||
const effectiveShowSafeZoneDebug = SHOW_SAFE_ZONE_DEBUG && isVisualDebugEnabled
|
||
|
||
// Calculate excluded regions (regions filtered out by size/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))
|
||
|
||
return excluded
|
||
}, [mapData, selectedMap, selectedContinent])
|
||
|
||
// Get current assistance level config
|
||
const currentAssistanceLevel = useMemo(() => {
|
||
return ASSISTANCE_LEVELS.find((level) => level.id === assistanceLevel) || ASSISTANCE_LEVELS[1] // Default to 'helpful'
|
||
}, [assistanceLevel])
|
||
|
||
// Whether hot/cold is allowed by the assistance level (not user preference)
|
||
const assistanceAllowsHotCold = currentAssistanceLevel?.hotColdEnabled ?? false
|
||
|
||
// Create a set of excluded region IDs for quick lookup
|
||
const excludedRegionIds = useMemo(
|
||
() => new Set(excludedRegions.map((r) => r.id)),
|
||
[excludedRegions]
|
||
)
|
||
|
||
const svgRef = useRef<SVGSVGElement>(null)
|
||
const containerRef = useRef<HTMLDivElement>(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<Map<string, { width: number; height: number }>>(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)
|
||
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 }
|
||
}
|
||
// Note: Zoom update now handled by useMagnifierZoom hook
|
||
}, [])
|
||
|
||
const handleLockReleased = useCallback(() => {
|
||
// Reset cursor squish
|
||
setCursorSquish({ x: 1, y: 1 })
|
||
setIsReleasingPointerLock(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)
|
||
// Initialize magnifier position within the safe zone (below nav/floating UI)
|
||
const [targetTop, setTargetTop] = useState(SAFE_ZONE_MARGINS.top)
|
||
const [targetLeft, setTargetLeft] = useState(SAFE_ZONE_MARGINS.left + 20)
|
||
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(Infinity)
|
||
const [shiftPressed, setShiftPressed] = useState(false)
|
||
|
||
// Track whether current target region needs magnification
|
||
const [targetNeedsMagnification, setTargetNeedsMagnification] = useState(false)
|
||
|
||
// Mobile magnifier touch drag state
|
||
const [isMagnifierDragging, setIsMagnifierDragging] = useState(false)
|
||
const magnifierTouchStartRef = useRef<{ x: number; y: number } | null>(null)
|
||
const magnifierDidMoveRef = useRef(false) // Track if user actually dragged (vs just tapped)
|
||
const magnifierRef = useRef<HTMLDivElement>(null) // Ref to magnifier element for tap position calculation
|
||
const magnifierTapPositionRef = useRef<{ x: number; y: number } | null>(null) // Where user tapped on magnifier
|
||
|
||
// Mobile map drag state - detect touch drags on the map to show magnifier
|
||
const [isMobileMapDragging, setIsMobileMapDragging] = useState(false)
|
||
const mapTouchStartRef = useRef<{ x: number; y: number } | null>(null)
|
||
const MOBILE_DRAG_THRESHOLD = 10 // pixels before we consider it a drag
|
||
|
||
// Give up reveal animation state
|
||
const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value
|
||
const [isGiveUpAnimating, setIsGiveUpAnimating] = useState(false) // Track if animation in progress
|
||
|
||
// Hint animation state
|
||
const [hintFlashProgress, setHintFlashProgress] = useState(0) // 0-1 pulsing value
|
||
const [isHintAnimating, setIsHintAnimating] = useState(false) // Track if animation in progress
|
||
|
||
// Celebration animation state
|
||
const [celebrationFlashProgress, setCelebrationFlashProgress] = useState(0) // 0-1 pulsing value
|
||
const pendingCelebrationClick = useRef<{ regionId: string; regionName: string } | null>(null)
|
||
// 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<DebugBoundingBox[]>([])
|
||
// Debug: Track full zoom search result for detailed panel
|
||
const [zoomSearchDebugInfo, setZoomSearchDebugInfo] = useState<ReturnType<
|
||
typeof findOptimalZoom
|
||
> | null>(null)
|
||
|
||
// Hint feature state
|
||
const [showHintBubble, setShowHintBubble] = useState(false)
|
||
// Determine which hint map to use:
|
||
// - For USA map, use 'usa'
|
||
// - For World map with specific continent, use the continent name (e.g., 'europe', 'africa')
|
||
// - For World map with 'all' continents, use 'world'
|
||
const hintMapKey: HintMap =
|
||
selectedMap === 'usa'
|
||
? 'usa'
|
||
: selectedContinent !== 'all'
|
||
? (selectedContinent as HintMap)
|
||
: 'world'
|
||
// Get hint for current region (if available)
|
||
const hintText = useRegionHint(hintMapKey, currentPrompt)
|
||
const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
|
||
|
||
// Get the current region name for audio hints
|
||
const currentRegionName = useMemo(() => {
|
||
if (!currentPrompt) return null
|
||
const region = mapData.regions.find((r) => r.id === currentPrompt)
|
||
return region?.name ?? null
|
||
}, [currentPrompt, mapData.regions])
|
||
|
||
// Get flag emoji for cursor label (world map only)
|
||
const currentFlagEmoji = useMemo(() => {
|
||
if (selectedMap !== 'world' || !currentPrompt) return ''
|
||
return getCountryFlagEmoji(currentPrompt)
|
||
}, [selectedMap, currentPrompt])
|
||
|
||
// Speech synthesis for reading hints aloud
|
||
const {
|
||
speakWithRegionName,
|
||
stop: stopSpeaking,
|
||
isSpeaking,
|
||
isSupported: isSpeechSupported,
|
||
hasAccentOption,
|
||
} = useSpeakHint(hintMapKey, currentPrompt)
|
||
|
||
// Auto-speak setting persisted in localStorage
|
||
const [autoSpeak, setAutoSpeak] = useState(() => {
|
||
if (typeof window === 'undefined') return false
|
||
return localStorage.getItem('knowYourWorld.autoSpeakHint') === 'true'
|
||
})
|
||
|
||
// With accent setting persisted in localStorage (default false - use user's locale for consistent pronunciation)
|
||
const [withAccent, setWithAccent] = useState(() => {
|
||
if (typeof window === 'undefined') return false
|
||
const stored = localStorage.getItem('knowYourWorld.withAccent')
|
||
return stored === null ? false : stored === 'true'
|
||
})
|
||
|
||
// Auto-hint setting persisted in localStorage (auto-opens hint on region advance)
|
||
const [autoHint, setAutoHint] = useState(() => {
|
||
if (typeof window === 'undefined') return false
|
||
return localStorage.getItem('knowYourWorld.autoHint') === 'true'
|
||
})
|
||
|
||
// Hot/cold audio feedback setting persisted in localStorage
|
||
const [hotColdEnabled, setHotColdEnabled] = useState(() => {
|
||
if (typeof window === 'undefined') return false
|
||
return localStorage.getItem('knowYourWorld.hotColdAudio') === 'true'
|
||
})
|
||
|
||
// Detect if device has a fine pointer (mouse) - iPads with mice will return true
|
||
// This is better than isTouchDevice because iPads with attached mice should show hot/cold
|
||
const hasFinePointer =
|
||
typeof window !== 'undefined' && window.matchMedia('(any-pointer: fine)').matches
|
||
|
||
// Whether hot/cold button should be shown at all
|
||
const showHotCold = isSpeechSupported && hasFinePointer && assistanceAllowsHotCold
|
||
|
||
// Persist auto-speak setting
|
||
const handleAutoSpeakChange = useCallback((enabled: boolean) => {
|
||
setAutoSpeak(enabled)
|
||
localStorage.setItem('knowYourWorld.autoSpeakHint', String(enabled))
|
||
}, [])
|
||
|
||
// Persist with-accent setting
|
||
const handleWithAccentChange = useCallback((enabled: boolean) => {
|
||
setWithAccent(enabled)
|
||
localStorage.setItem('knowYourWorld.withAccent', String(enabled))
|
||
}, [])
|
||
|
||
// Persist auto-hint setting
|
||
const handleAutoHintChange = useCallback((enabled: boolean) => {
|
||
setAutoHint(enabled)
|
||
localStorage.setItem('knowYourWorld.autoHint', String(enabled))
|
||
}, [])
|
||
|
||
// Persist hot/cold audio setting
|
||
const handleHotColdChange = useCallback((enabled: boolean) => {
|
||
setHotColdEnabled(enabled)
|
||
localStorage.setItem('knowYourWorld.hotColdAudio', String(enabled))
|
||
}, [])
|
||
|
||
// Speak hint callback
|
||
const handleSpeakClick = useCallback(() => {
|
||
if (isSpeaking) {
|
||
stopSpeaking()
|
||
} else if (currentRegionName) {
|
||
speakWithRegionName(currentRegionName, hintText, withAccent)
|
||
}
|
||
}, [isSpeaking, stopSpeaking, currentRegionName, hintText, speakWithRegionName, withAccent])
|
||
|
||
// Auto-speak toggle callback
|
||
const handleAutoSpeakToggle = useCallback(() => {
|
||
handleAutoSpeakChange(!autoSpeak)
|
||
}, [autoSpeak, handleAutoSpeakChange])
|
||
|
||
// With accent toggle callback
|
||
const handleWithAccentToggle = useCallback(() => {
|
||
handleWithAccentChange(!withAccent)
|
||
}, [withAccent, handleWithAccentChange])
|
||
|
||
// Auto-hint toggle callback
|
||
const handleAutoHintToggle = useCallback(() => {
|
||
handleAutoHintChange(!autoHint)
|
||
}, [autoHint, handleAutoHintChange])
|
||
|
||
// Hot/cold toggle callback
|
||
const handleHotColdToggle = useCallback(() => {
|
||
handleHotColdChange(!hotColdEnabled)
|
||
}, [hotColdEnabled, handleHotColdChange])
|
||
|
||
// Track previous showHintBubble state to detect when it opens
|
||
const prevShowHintBubbleRef = useRef(false)
|
||
|
||
// Auto-speak hint when bubble opens (if enabled)
|
||
// Only triggers when bubble transitions from closed to open, not when hintText changes
|
||
useEffect(() => {
|
||
const justOpened = showHintBubble && !prevShowHintBubbleRef.current
|
||
prevShowHintBubbleRef.current = showHintBubble
|
||
|
||
if (justOpened && autoSpeak && currentRegionName && isSpeechSupported) {
|
||
speakWithRegionName(currentRegionName, hintText, withAccent)
|
||
}
|
||
}, [
|
||
showHintBubble,
|
||
autoSpeak,
|
||
currentRegionName,
|
||
hintText,
|
||
isSpeechSupported,
|
||
speakWithRegionName,
|
||
withAccent,
|
||
])
|
||
|
||
// Track previous prompt to detect region changes
|
||
const prevPromptRef = useRef<string | null>(null)
|
||
// Store autoHint/autoSpeak in refs so we can read current values without triggering effect
|
||
const autoHintRef = useRef(autoHint)
|
||
const autoSpeakRef = useRef(autoSpeak)
|
||
const withAccentRef = useRef(withAccent)
|
||
// Hot/cold is only active when both: 1) assistance level allows it, 2) user has it enabled
|
||
const effectiveHotColdEnabled = assistanceAllowsHotCold && hotColdEnabled
|
||
const hotColdEnabledRef = useRef(effectiveHotColdEnabled)
|
||
autoHintRef.current = autoHint
|
||
autoSpeakRef.current = autoSpeak
|
||
withAccentRef.current = withAccent
|
||
hotColdEnabledRef.current = effectiveHotColdEnabled
|
||
|
||
// Handle hint bubble and auto-speak when the prompt changes (new region to find)
|
||
// Also re-runs when hintsLocked changes (e.g., user unlocked hints by typing name)
|
||
useEffect(() => {
|
||
const isNewRegion = prevPromptRef.current !== null && prevPromptRef.current !== currentPrompt
|
||
prevPromptRef.current = currentPrompt
|
||
|
||
// Don't auto-show hints when locked (e.g., waiting for name confirmation)
|
||
if (autoHintRef.current && hasHint && !hintsLocked) {
|
||
setShowHintBubble(true)
|
||
// If region changed and both auto-hint and auto-speak are enabled, speak immediately
|
||
// This handles the case where the bubble was already open
|
||
if (isNewRegion && autoSpeakRef.current && currentRegionName && isSpeechSupported) {
|
||
speakWithRegionName(currentRegionName, hintText, withAccentRef.current)
|
||
}
|
||
} else {
|
||
setShowHintBubble(false)
|
||
}
|
||
}, [
|
||
currentPrompt,
|
||
hasHint,
|
||
currentRegionName,
|
||
hintText,
|
||
isSpeechSupported,
|
||
speakWithRegionName,
|
||
hintsLocked,
|
||
])
|
||
|
||
// Hot/cold audio feedback hook
|
||
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
|
||
// Use continent name for language lookup if available, otherwise use selectedMap
|
||
const hotColdMapName = selectedContinent || selectedMap
|
||
const {
|
||
checkPosition: checkHotCold,
|
||
reset: resetHotCold,
|
||
lastFeedbackType: hotColdFeedbackType,
|
||
getSearchMetrics,
|
||
} = useHotColdFeedback({
|
||
enabled: assistanceAllowsHotCold && hotColdEnabled && hasFinePointer,
|
||
targetRegionId: currentPrompt,
|
||
isSpeaking,
|
||
mapName: hotColdMapName,
|
||
regions: mapData.regions,
|
||
})
|
||
|
||
// Reset hot/cold feedback when prompt changes
|
||
useEffect(() => {
|
||
resetHotCold()
|
||
}, [currentPrompt, resetHotCold])
|
||
|
||
// Update context with controls state for GameInfoPanel
|
||
useEffect(() => {
|
||
setControlsState({
|
||
isPointerLocked: pointerLocked,
|
||
fakeCursorPosition: cursorPosition,
|
||
showHotCold,
|
||
hotColdEnabled,
|
||
hotColdFeedbackType,
|
||
onHotColdToggle: handleHotColdToggle,
|
||
hasHint,
|
||
currentHint: hintText,
|
||
isGiveUpAnimating,
|
||
// Speech/audio state
|
||
isSpeechSupported,
|
||
hasAccentOption,
|
||
isSpeaking,
|
||
onSpeak: handleSpeakClick,
|
||
onStopSpeaking: stopSpeaking,
|
||
// Auto settings
|
||
autoSpeak,
|
||
onAutoSpeakToggle: handleAutoSpeakToggle,
|
||
withAccent,
|
||
onWithAccentToggle: handleWithAccentToggle,
|
||
autoHint,
|
||
onAutoHintToggle: handleAutoHintToggle,
|
||
})
|
||
}, [
|
||
pointerLocked,
|
||
cursorPosition,
|
||
showHotCold,
|
||
hotColdEnabled,
|
||
hotColdFeedbackType,
|
||
handleHotColdToggle,
|
||
hasHint,
|
||
hintText,
|
||
isGiveUpAnimating,
|
||
setControlsState,
|
||
// Speech/audio deps
|
||
isSpeechSupported,
|
||
hasAccentOption,
|
||
isSpeaking,
|
||
handleSpeakClick,
|
||
stopSpeaking,
|
||
// Auto settings deps
|
||
autoSpeak,
|
||
handleAutoSpeakToggle,
|
||
withAccent,
|
||
handleWithAccentToggle,
|
||
autoHint,
|
||
handleAutoHintToggle,
|
||
])
|
||
|
||
// 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<string, { width: number; height: number }>()
|
||
|
||
mapData.regions.forEach((region) => {
|
||
const pathData = region.path
|
||
// Split on z followed by m (Safari doesn't support lookbehind, so use replace + split)
|
||
const withSeparator = pathData.replace(/z\s*m/gi, 'z|||m')
|
||
const rawPieces = withSeparator.split('|||')
|
||
|
||
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)
|
||
}
|
||
})
|
||
|
||
largestPieceSizesRef.current = largestPieceSizes
|
||
}, [mapData])
|
||
|
||
// Check if pointer lock is supported (not available on touch devices like iPad)
|
||
const isPointerLockSupported = typeof document !== 'undefined' && 'pointerLockElement' in document
|
||
|
||
// Request pointer lock on first click
|
||
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
// Silently request pointer lock if not already locked (and supported)
|
||
// This makes the first gameplay click also enable precision mode
|
||
// On devices without pointer lock (iPad), skip this and process clicks normally
|
||
if (!pointerLocked && isPointerLockSupported) {
|
||
requestPointerLock()
|
||
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
|
||
|
||
// Use the same detection logic as hover tracking (50px detection box)
|
||
const { detectedRegions, regionUnderCursor } = detectRegions(cursorX, cursorY)
|
||
|
||
if (regionUnderCursor && !celebration) {
|
||
// Find the region data to get the name
|
||
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
||
if (region) {
|
||
handleRegionClickWithCelebration(regionUnderCursor, region.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// When fillContainer is true (playing phase), we use safe zone margins to ensure
|
||
// the crop region doesn't appear under floating UI elements
|
||
const displayViewBox = useMemo(() => {
|
||
// Need container dimensions to calculate aspect ratio
|
||
if (svgDimensions.width <= 0 || svgDimensions.height <= 0) {
|
||
return mapData.viewBox
|
||
}
|
||
|
||
const originalBounds = parseViewBox(mapData.originalViewBox)
|
||
|
||
// Use custom crop if defined, otherwise use the full original map bounds
|
||
// This ensures the map always fits within the leftover area (not under UI elements)
|
||
const cropRegion = mapData.customCrop ? parseViewBox(mapData.customCrop) : originalBounds
|
||
|
||
// In full-viewport mode (fillContainer), use safe zone calculation to ensure
|
||
// the crop region fits within the area not covered by floating UI elements
|
||
if (fillContainer) {
|
||
const result = calculateSafeZoneViewBox(
|
||
svgDimensions.width,
|
||
svgDimensions.height,
|
||
SAFE_ZONE_MARGINS,
|
||
cropRegion,
|
||
originalBounds
|
||
)
|
||
return result
|
||
}
|
||
|
||
// If not fillContainer and no custom crop, just use regular viewBox
|
||
if (!mapData.customCrop) {
|
||
return mapData.viewBox
|
||
}
|
||
|
||
// Otherwise use standard fit-crop calculation (for setup phase, etc.)
|
||
const containerAspect = svgDimensions.width / svgDimensions.height
|
||
const result = calculateFitCropViewBox(originalBounds, cropRegion, containerAspect)
|
||
return result
|
||
}, [mapData.customCrop, mapData.originalViewBox, mapData.viewBox, svgDimensions, fillContainer])
|
||
|
||
// 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<string, { playerId: string; color: string }> = {}
|
||
|
||
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 the transmitted hoveredRegionId directly (avoids hit-testing discrepancies
|
||
// due to pixel scaling/rendering differences between clients)
|
||
if (position.hoveredRegionId) {
|
||
result[position.hoveredRegionId] = { playerId, color: player.color }
|
||
}
|
||
})
|
||
|
||
return result
|
||
}, [otherPlayerCursors, localPlayerId, gameMode, currentPlayer, playerMetadata])
|
||
|
||
// 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 },
|
||
})
|
||
|
||
// 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)
|
||
}, [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<typeof setTimeout> | null = null
|
||
|
||
// Start animation
|
||
setIsGiveUpAnimating(true)
|
||
|
||
// 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
|
||
// Add nav offset when in full-viewport mode
|
||
const buttonTop = svgOffsetY + 8 + (fillContainer ? NAV_HEIGHT_OFFSET : 0)
|
||
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}"]`)
|
||
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
|
||
|
||
// Start zoom-in animation using CSS transform
|
||
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) {
|
||
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
|
||
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)
|
||
}
|
||
}
|
||
}, [giveUpReveal?.timestamp]) // Re-run when timestamp changes
|
||
|
||
// Hint animation effect - brief pulse to highlight target region
|
||
useEffect(() => {
|
||
if (!hintActive) {
|
||
setHintFlashProgress(0)
|
||
setIsHintAnimating(false)
|
||
return
|
||
}
|
||
|
||
// Track if this effect has been cleaned up
|
||
let isCancelled = false
|
||
let animationFrameId: number | null = null
|
||
|
||
// Start animation
|
||
setIsHintAnimating(true)
|
||
|
||
// Animation: 2 pulses over 1.5 seconds (shorter than give-up)
|
||
const duration = 1500
|
||
const pulses = 2
|
||
const startTime = Date.now()
|
||
|
||
const animate = () => {
|
||
if (isCancelled) 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
|
||
setHintFlashProgress(pulseProgress)
|
||
|
||
if (progress < 1) {
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
} else {
|
||
// Animation complete
|
||
setHintFlashProgress(0)
|
||
setIsHintAnimating(false)
|
||
}
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
|
||
// Cleanup
|
||
return () => {
|
||
isCancelled = true
|
||
if (animationFrameId !== null) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
}
|
||
}
|
||
}, [hintActive?.timestamp]) // Re-run when timestamp changes
|
||
|
||
// Celebration animation effect - gold flash and confetti when region found
|
||
useEffect(() => {
|
||
if (!celebration) {
|
||
setCelebrationFlashProgress(0)
|
||
return
|
||
}
|
||
|
||
// Track if this effect has been cleaned up
|
||
let isCancelled = false
|
||
let animationFrameId: number | null = null
|
||
|
||
// Animation: pulsing gold flash during celebration
|
||
const timing = CELEBRATION_TIMING[celebration.type]
|
||
const duration = timing.totalDuration
|
||
const pulses = celebration.type === 'lightning' ? 2 : celebration.type === 'standard' ? 3 : 4
|
||
const startTime = Date.now()
|
||
|
||
const animate = () => {
|
||
if (isCancelled) 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
|
||
setCelebrationFlashProgress(pulseProgress)
|
||
|
||
if (progress < 1) {
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
}
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(animate)
|
||
|
||
// Cleanup
|
||
return () => {
|
||
isCancelled = true
|
||
if (animationFrameId !== null) {
|
||
cancelAnimationFrame(animationFrameId)
|
||
}
|
||
}
|
||
}, [celebration?.startTime]) // Re-run when celebration starts
|
||
|
||
// Handle celebration completion - call the actual click after animation
|
||
const handleCelebrationComplete = useCallback(() => {
|
||
const pending = pendingCelebrationClick.current
|
||
if (pending) {
|
||
// Clear celebration state first
|
||
setCelebration(null)
|
||
setCelebrationFlashProgress(0)
|
||
// Then fire the actual click
|
||
onRegionClick(pending.regionId, pending.regionName)
|
||
pendingCelebrationClick.current = null
|
||
}
|
||
}, [setCelebration, onRegionClick])
|
||
|
||
// Wrapper function to intercept clicks and trigger celebration for correct regions
|
||
const handleRegionClickWithCelebration = useCallback(
|
||
(regionId: string, regionName: string) => {
|
||
// If we're already celebrating, ignore clicks
|
||
if (celebration) return
|
||
|
||
// Check if this is the correct region
|
||
if (regionId === currentPrompt) {
|
||
// Correct! Start celebration
|
||
const metrics = getSearchMetrics(promptStartTime.current)
|
||
const celebrationType = classifyCelebration(metrics)
|
||
|
||
// Store pending click for after celebration
|
||
pendingCelebrationClick.current = { regionId, regionName }
|
||
|
||
// Start celebration
|
||
setCelebration({
|
||
regionId,
|
||
regionName,
|
||
type: celebrationType,
|
||
startTime: Date.now(),
|
||
})
|
||
} else {
|
||
// Wrong region - handle immediately
|
||
onRegionClick(regionId, regionName)
|
||
}
|
||
},
|
||
[celebration, currentPrompt, getSearchMetrics, promptStartTime, setCelebration, onRegionClick]
|
||
)
|
||
|
||
// Get center of celebrating region for confetti origin
|
||
const getCelebrationRegionCenter = useCallback((): { x: number; y: number } => {
|
||
if (!celebration || !svgRef.current || !containerRef.current) {
|
||
return { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||
}
|
||
|
||
const region = mapData.regions.find((r) => r.id === celebration.regionId)
|
||
if (!region) {
|
||
return { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||
}
|
||
|
||
// 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 viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
|
||
// Get absolute screen position
|
||
const screenX = containerRect.left + (region.center[0] - viewBoxX) * viewport.scale + svgOffsetX
|
||
const screenY = containerRect.top + (region.center[1] - viewBoxY) * viewport.scale + svgOffsetY
|
||
|
||
return { x: screenX, y: screenY }
|
||
}, [celebration, mapData.regions, displayViewBox])
|
||
|
||
// Keyboard shortcuts - Shift for magnifier, H for hint
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// Don't trigger shortcuts if user is typing in an input
|
||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||
return
|
||
}
|
||
|
||
if (e.key === 'Shift' && !e.repeat) {
|
||
setShiftPressed(true)
|
||
}
|
||
|
||
// 'H' key to toggle hint bubble
|
||
if ((e.key === 'h' || e.key === 'H') && !e.repeat && hasHint) {
|
||
setShowHintBubble((prev) => !prev)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}, [hasHint])
|
||
|
||
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 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
|
||
|
||
// Get the actual rendered viewport accounting for preserveAspectRatio letterboxing
|
||
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxWidth, viewBoxHeight)
|
||
const scaleX = viewport.scale
|
||
const scaleY = viewport.scale // Same as scaleX due to uniform scaling
|
||
|
||
// Calculate SVG offset within container (accounts for padding + letterboxing)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
|
||
// 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 (commented out - too spammy)
|
||
// 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 it's small and arrows enabled
|
||
// Note: Excluded regions do NOT get labels - they're just grayed out
|
||
const shouldShowLabel =
|
||
regionsFound.includes(region.id) || (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 removed to reduce spam
|
||
}
|
||
|
||
// 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 => {
|
||
// Learning/Guided/Helpful modes: always show outlines
|
||
if (
|
||
assistanceLevel === 'learning' ||
|
||
assistanceLevel === 'guided' ||
|
||
assistanceLevel === 'helpful'
|
||
)
|
||
return true
|
||
|
||
// Standard/None modes: 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
|
||
|
||
// 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
|
||
)
|
||
|
||
// Check if cursor has squished through and should escape (using dampened position!)
|
||
if (dampenedMinDist < escapeThreshold && !isReleasingPointerLock) {
|
||
// 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
|
||
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 })
|
||
|
||
// Note: Button hover detection is handled by usePointerLockButton hooks
|
||
|
||
// Use region detection hook to find regions near cursor
|
||
const detectionResult = detectRegions(cursorX, cursorY)
|
||
const {
|
||
detectedRegions: detectedRegionObjects,
|
||
regionUnderCursor,
|
||
regionUnderCursorArea,
|
||
regionsInBox,
|
||
hasSmallRegion,
|
||
detectedSmallestSize,
|
||
totalRegionArea,
|
||
} = detectionResult
|
||
|
||
// Show magnifier when:
|
||
// 1. Shift key is held down (manual override on desktop)
|
||
// 2. Current target region needs magnification AND there's a small region nearby
|
||
// 3. User is dragging on the map on mobile (always show magnifier for mobile drag)
|
||
const shouldShow =
|
||
shiftPressed || isMobileMapDragging || (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)
|
||
}
|
||
|
||
// Hot/cold audio feedback
|
||
// Only run if enabled, we have a target region, device has a fine pointer (mouse),
|
||
// and user can actually see/interact with the map (not during animations or takeover)
|
||
if (
|
||
hotColdEnabledRef.current &&
|
||
currentPrompt &&
|
||
hasFinePointer &&
|
||
!isGiveUpAnimating &&
|
||
!isInTakeover
|
||
) {
|
||
// Find target region's SVG center
|
||
const targetRegion = mapData.regions.find((r) => r.id === currentPrompt)
|
||
if (targetRegion) {
|
||
// Parse viewBox for coordinate conversion
|
||
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
|
||
// Convert SVG center to pixel coordinates
|
||
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const targetPixelX = (targetRegion.center[0] - viewBoxX) * viewport.scale + svgOffsetX
|
||
const targetPixelY = (targetRegion.center[1] - viewBoxY) * viewport.scale + svgOffsetY
|
||
|
||
// Calculate cursor position in SVG coordinates for finding closest region (for accent)
|
||
const cursorSvgX = (cursorX - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorY - svgOffsetY) / viewport.scale + viewBoxY
|
||
|
||
checkHotCold({
|
||
cursorPosition: { x: cursorX, y: cursorY },
|
||
targetCenter: { x: targetPixelX, y: targetPixelY },
|
||
hoveredRegionId: regionUnderCursor,
|
||
cursorSvgPosition: { x: cursorSvgX, y: cursorSvgY },
|
||
})
|
||
}
|
||
}
|
||
|
||
// Send cursor position to other players (in SVG coordinates)
|
||
// In turn-based mode, only broadcast when it's our turn
|
||
// We do this AFTER detectRegions so we can include the exact hovered region
|
||
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
|
||
// Account for preserveAspectRatio letterboxing when converting to SVG coords
|
||
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
// Use inverse of viewport.scale to convert pixels to viewBox units
|
||
const cursorSvgX = (cursorX - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorY - svgOffsetY) / viewport.scale + viewBoxY
|
||
// Pass the exact region under cursor (from local hit-testing) so other clients
|
||
// don't need to re-do hit-testing which can yield different results due to scaling
|
||
onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }, 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 leftover rectangle dimensions (area not covered by UI elements)
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
// Calculate magnifier dimensions based on leftover rectangle (responsive to its aspect ratio)
|
||
const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
// Lazy magnifier positioning: only move if cursor would be obscured
|
||
// Check if cursor is within current magnifier bounds (with padding)
|
||
const padding = 10 // pixels of buffer around magnifier
|
||
const currentMagLeft = targetLeft
|
||
const currentMagTop = targetTop
|
||
const currentMagRight = currentMagLeft + magnifierWidth
|
||
const currentMagBottom = currentMagTop + magnifierHeight
|
||
|
||
const cursorInMagnifier =
|
||
cursorX >= currentMagLeft - padding &&
|
||
cursorX <= currentMagRight + padding &&
|
||
cursorY >= currentMagTop - padding &&
|
||
cursorY <= currentMagBottom + padding
|
||
|
||
// Only calculate new position if cursor would be obscured
|
||
let newTop = targetTop
|
||
let newLeft = targetLeft
|
||
|
||
if (cursorInMagnifier) {
|
||
// Calculate leftover rectangle bounds (where magnifier can safely be positioned)
|
||
const leftoverTop = SAFE_ZONE_MARGINS.top
|
||
const leftoverBottom =
|
||
containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20
|
||
const leftoverLeft = SAFE_ZONE_MARGINS.left + 20
|
||
const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20
|
||
|
||
// Calculate the center of the leftover rectangle for positioning decisions
|
||
const leftoverCenterX = (leftoverLeft + leftoverRight + magnifierWidth) / 2
|
||
const leftoverCenterY = (leftoverTop + leftoverBottom + magnifierHeight) / 2
|
||
|
||
// Move to opposite corner from cursor (relative to leftover rectangle center)
|
||
const isLeftHalf = cursorX < leftoverCenterX
|
||
const isTopHalf = cursorY < leftoverCenterY
|
||
|
||
// Default: opposite corner from cursor, within leftover bounds
|
||
newTop = isTopHalf ? leftoverBottom : leftoverTop
|
||
newLeft = isLeftHalf ? leftoverRight : leftoverLeft
|
||
|
||
// When hint bubble is shown, blacklist the upper-right corner
|
||
// If magnifier would go to top-right (cursor in bottom-left), go to bottom-right instead
|
||
const wouldGoToTopRight = !isTopHalf && isLeftHalf
|
||
if (showHintBubble && wouldGoToTopRight) {
|
||
newTop = leftoverBottom // Move to bottom
|
||
// newLeft stays at right
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
// Calculate leftover rectangle dimensions for magnifier sizing
|
||
const leftoverWidthForCap =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeightForCap =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidthForCap,
|
||
leftoverHeightForCap
|
||
)
|
||
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)
|
||
// Zoom cap log removed to reduce spam
|
||
}
|
||
}
|
||
}
|
||
|
||
setTargetZoom(adaptiveZoom)
|
||
setShowMagnifier(true)
|
||
setTargetOpacity(1)
|
||
setTargetTop(newTop)
|
||
setTargetLeft(newLeft)
|
||
} else {
|
||
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) {
|
||
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, null)
|
||
}
|
||
}
|
||
|
||
// Mobile map touch handlers - detect drag gestures to show magnifier
|
||
const handleMapTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
|
||
// Only handle single-finger touch
|
||
if (e.touches.length !== 1) return
|
||
|
||
const touch = e.touches[0]
|
||
mapTouchStartRef.current = { x: touch.clientX, y: touch.clientY }
|
||
}, [])
|
||
|
||
const handleMapTouchMove = useCallback(
|
||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||
if (!mapTouchStartRef.current || !svgRef.current || !containerRef.current) return
|
||
if (e.touches.length !== 1) return
|
||
|
||
const touch = e.touches[0]
|
||
const dx = touch.clientX - mapTouchStartRef.current.x
|
||
const dy = touch.clientY - mapTouchStartRef.current.y
|
||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||
|
||
// Once we detect a drag (moved past threshold), show magnifier and update cursor
|
||
if (distance >= MOBILE_DRAG_THRESHOLD) {
|
||
// Prevent default to stop text selection and other browser gestures
|
||
e.preventDefault()
|
||
|
||
if (!isMobileMapDragging) {
|
||
setIsMobileMapDragging(true)
|
||
}
|
||
|
||
// Update cursor position based on touch location
|
||
const containerRect = containerRef.current.getBoundingClientRect()
|
||
const cursorX = touch.clientX - containerRect.left
|
||
const cursorY = touch.clientY - containerRect.top
|
||
|
||
cursorPositionRef.current = { x: cursorX, y: cursorY }
|
||
setCursorPosition({ x: cursorX, y: cursorY })
|
||
|
||
// Show magnifier and set it up for mobile drag
|
||
setShowMagnifier(true)
|
||
setTargetOpacity(1)
|
||
|
||
// Use adaptive zoom from region detection if available
|
||
const detectionResult = detectRegions(cursorX, cursorY)
|
||
const { detectedRegions: detectedRegionObjects, detectedSmallestSize } = detectionResult
|
||
|
||
// Filter out found regions from zoom calculations (same as desktop)
|
||
const unfoundRegionObjects = detectedRegionObjects.filter(
|
||
(r) => !regionsFound.includes(r.id)
|
||
)
|
||
|
||
// Use adaptive zoom search utility to find optimal zoom (same algorithm as desktop)
|
||
const svgRect = svgRef.current.getBoundingClientRect()
|
||
const zoomSearchResult = findOptimalZoom({
|
||
detectedRegions: unfoundRegionObjects,
|
||
detectedSmallestSize,
|
||
cursorX,
|
||
cursorY,
|
||
containerRect,
|
||
svgRect,
|
||
mapData,
|
||
svgElement: svgRef.current,
|
||
largestPieceSizesCache: largestPieceSizesRef.current,
|
||
maxZoom: MAX_ZOOM,
|
||
minZoom: 1,
|
||
pointerLocked: false, // Mobile never uses pointer lock
|
||
})
|
||
|
||
setTargetZoom(zoomSearchResult.zoom)
|
||
|
||
// Calculate leftover rectangle dimensions (area not covered by UI elements)
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
// Get magnifier dimensions based on leftover rectangle (responsive to its aspect ratio)
|
||
const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
// Calculate leftover rectangle bounds (where magnifier can safely be positioned)
|
||
const leftoverTop = SAFE_ZONE_MARGINS.top
|
||
const leftoverBottom =
|
||
containerRect.height - SAFE_ZONE_MARGINS.bottom - magnifierHeight - 20
|
||
const leftoverLeft = SAFE_ZONE_MARGINS.left + 20
|
||
const leftoverRight = containerRect.width - SAFE_ZONE_MARGINS.right - magnifierWidth - 20
|
||
|
||
// Calculate the center of the leftover rectangle for positioning decisions
|
||
const leftoverCenterX = (leftoverLeft + leftoverRight + magnifierWidth) / 2
|
||
const leftoverCenterY = (leftoverTop + leftoverBottom + magnifierHeight) / 2
|
||
|
||
// Position magnifier away from touch point (relative to leftover rectangle center)
|
||
const isLeftHalf = cursorX < leftoverCenterX
|
||
const isTopHalf = cursorY < leftoverCenterY
|
||
|
||
// Place magnifier in opposite corner from where user is touching, within leftover bounds
|
||
const newTop = isTopHalf ? leftoverBottom : leftoverTop
|
||
const newLeft = isLeftHalf ? leftoverRight : leftoverLeft
|
||
|
||
setTargetTop(newTop)
|
||
setTargetLeft(newLeft)
|
||
}
|
||
},
|
||
[
|
||
isMobileMapDragging,
|
||
MOBILE_DRAG_THRESHOLD,
|
||
detectRegions,
|
||
MAX_ZOOM,
|
||
getMagnifierDimensions,
|
||
regionsFound,
|
||
mapData,
|
||
]
|
||
)
|
||
|
||
// Helper to dismiss the magnifier (used by tap-to-dismiss and after selection)
|
||
const dismissMagnifier = useCallback(() => {
|
||
setShowMagnifier(false)
|
||
setTargetOpacity(0)
|
||
setCursorPosition(null)
|
||
cursorPositionRef.current = null
|
||
}, [])
|
||
|
||
const handleMapTouchEnd = useCallback(() => {
|
||
const wasDragging = isMobileMapDragging
|
||
mapTouchStartRef.current = null
|
||
|
||
if (wasDragging) {
|
||
setIsMobileMapDragging(false)
|
||
// Keep magnifier visible after drag ends - user can tap "Select" button or tap elsewhere to dismiss
|
||
// Don't hide magnifier or clear cursor - leave them in place for selection
|
||
} else if (showMagnifier && cursorPositionRef.current) {
|
||
// User tapped on map (not a drag) while magnifier is visible - dismiss the magnifier
|
||
dismissMagnifier()
|
||
}
|
||
}, [isMobileMapDragging, showMagnifier, dismissMagnifier])
|
||
|
||
// Mobile magnifier touch handlers - allow panning by dragging on the magnifier
|
||
const handleMagnifierTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
|
||
if (e.touches.length !== 1) return // Only handle single-finger touch
|
||
|
||
const touch = e.touches[0]
|
||
magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY }
|
||
magnifierDidMoveRef.current = false // Reset movement tracking
|
||
|
||
// Record tap position relative to magnifier for tap-to-select
|
||
if (magnifierRef.current) {
|
||
const magnifierRect = magnifierRef.current.getBoundingClientRect()
|
||
magnifierTapPositionRef.current = {
|
||
x: touch.clientX - magnifierRect.left,
|
||
y: touch.clientY - magnifierRect.top,
|
||
}
|
||
}
|
||
|
||
setIsMagnifierDragging(true)
|
||
e.preventDefault() // Prevent scrolling
|
||
}, [])
|
||
|
||
const handleMagnifierTouchMove = useCallback(
|
||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||
if (!isMagnifierDragging || e.touches.length !== 1) return
|
||
if (!magnifierTouchStartRef.current || !cursorPositionRef.current) return
|
||
if (!svgRef.current || !containerRef.current) return
|
||
|
||
const touch = e.touches[0]
|
||
const deltaX = touch.clientX - magnifierTouchStartRef.current.x
|
||
const deltaY = touch.clientY - magnifierTouchStartRef.current.y
|
||
|
||
// Track if user has moved significantly (more than 5px = definitely a drag, not a tap)
|
||
if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
|
||
magnifierDidMoveRef.current = true
|
||
}
|
||
|
||
// Update start position for next move
|
||
magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY }
|
||
|
||
// Scale touch delta by zoom level for 1:1 panning feel.
|
||
//
|
||
// The magnifier shows a zoomed view of the map. When zoomed 3x:
|
||
// - Moving cursor by 1 pixel shifts the magnifier view by 3 pixels
|
||
// - To get 1:1 feel (finger moves N pixels = content moves N pixels in magnifier),
|
||
// we divide finger movement by zoom level
|
||
//
|
||
// This makes dragging feel like moving the map under a fixed magnifying glass.
|
||
const currentZoom = getCurrentZoom()
|
||
const touchMultiplier = 1 / currentZoom
|
||
|
||
// Invert the delta - dragging right on magnifier should show content to the right
|
||
// (which means moving the cursor right in the map coordinate space)
|
||
// Actually, dragging the "paper" under the magnifier means:
|
||
// - Drag finger right = paper moves right = magnifier shows what was to the LEFT
|
||
// - So we SUBTRACT the delta to move the cursor in the opposite direction
|
||
const newCursorX = cursorPositionRef.current.x - deltaX * touchMultiplier
|
||
const newCursorY = cursorPositionRef.current.y - deltaY * touchMultiplier
|
||
|
||
// Clamp to SVG bounds
|
||
const containerRect = containerRef.current.getBoundingClientRect()
|
||
const svgRect = svgRef.current.getBoundingClientRect()
|
||
const svgOffsetX = svgRect.left - containerRect.left
|
||
const svgOffsetY = svgRect.top - containerRect.top
|
||
|
||
const clampedX = Math.max(svgOffsetX, Math.min(svgOffsetX + svgRect.width, newCursorX))
|
||
const clampedY = Math.max(svgOffsetY, Math.min(svgOffsetY + svgRect.height, newCursorY))
|
||
|
||
// Update cursor position
|
||
cursorPositionRef.current = { x: clampedX, y: clampedY }
|
||
setCursorPosition({ x: clampedX, y: clampedY })
|
||
|
||
// Run region detection to update hoveredRegionId (so user sees which region is under cursor)
|
||
// We don't update zoom during drag to avoid disorienting zoom changes while panning
|
||
const { regionUnderCursor } = detectRegions(clampedX, clampedY)
|
||
|
||
// Broadcast cursor update to other players (if in multiplayer)
|
||
if (
|
||
onCursorUpdate &&
|
||
(gameMode !== 'turn-based' || currentPlayer === localPlayerId) &&
|
||
containerRef.current &&
|
||
svgRef.current
|
||
) {
|
||
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 viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetXWithLetterbox = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetYWithLetterbox = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgX = (clampedX - svgOffsetXWithLetterbox) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (clampedY - svgOffsetYWithLetterbox) / viewport.scale + viewBoxY
|
||
onCursorUpdate({ x: cursorSvgX, y: cursorSvgY }, regionUnderCursor)
|
||
}
|
||
|
||
e.preventDefault() // Prevent scrolling
|
||
},
|
||
[
|
||
isMagnifierDragging,
|
||
detectRegions,
|
||
onCursorUpdate,
|
||
gameMode,
|
||
currentPlayer,
|
||
localPlayerId,
|
||
displayViewBox,
|
||
getCurrentZoom,
|
||
]
|
||
)
|
||
|
||
const handleMagnifierTouchEnd = useCallback(
|
||
(e: React.TouchEvent<HTMLDivElement>) => {
|
||
// Check if this was a tap (no significant movement) vs a drag
|
||
// If the user just tapped on the magnifier, select the region at the tap position
|
||
const didMove = magnifierDidMoveRef.current
|
||
const tapPosition = magnifierTapPositionRef.current
|
||
setIsMagnifierDragging(false)
|
||
magnifierTouchStartRef.current = null
|
||
magnifierDidMoveRef.current = false
|
||
magnifierTapPositionRef.current = null
|
||
|
||
// If there was a changed touch that ended and it wasn't a drag, check for tap-to-select
|
||
if (e.changedTouches.length === 1 && !didMove && tapPosition) {
|
||
// Convert tap position on magnifier to SVG coordinates
|
||
if (
|
||
magnifierRef.current &&
|
||
svgRef.current &&
|
||
containerRef.current &&
|
||
cursorPositionRef.current
|
||
) {
|
||
const magnifierRect = magnifierRef.current.getBoundingClientRect()
|
||
const containerRect = containerRef.current.getBoundingClientRect()
|
||
const svgRect = svgRef.current.getBoundingClientRect()
|
||
|
||
// Get the current zoom level
|
||
const currentZoom = zoomSpring.get()
|
||
|
||
// Parse the main map viewBox
|
||
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] || 1000
|
||
|
||
// Get viewport info for coordinate conversion
|
||
const viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
|
||
// Current cursor position in SVG coordinates (center of magnifier view)
|
||
const cursorSvgX = (cursorPositionRef.current.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorPositionRef.current.y - svgOffsetY) / viewport.scale + viewBoxY
|
||
|
||
// Magnifier viewBox dimensions
|
||
const magnifiedWidth = viewBoxW / currentZoom
|
||
const magnifiedHeight = viewBoxH / currentZoom
|
||
|
||
// Convert tap position (relative to magnifier) to SVG coordinates
|
||
// Tap at (0,0) is top-left of magnifier = cursorSvg - magnifiedSize/2
|
||
// Tap at (magnifierWidth, magnifierHeight) is bottom-right = cursorSvg + magnifiedSize/2
|
||
const tapSvgX =
|
||
cursorSvgX - magnifiedWidth / 2 + (tapPosition.x / magnifierRect.width) * magnifiedWidth
|
||
const tapSvgY =
|
||
cursorSvgY -
|
||
magnifiedHeight / 2 +
|
||
(tapPosition.y / magnifierRect.height) * magnifiedHeight
|
||
|
||
// Convert SVG coordinates back to container coordinates for region detection
|
||
const tapContainerX = (tapSvgX - viewBoxX) * viewport.scale + svgOffsetX
|
||
const tapContainerY = (tapSvgY - viewBoxY) * viewport.scale + svgOffsetY
|
||
|
||
// Run region detection at the tap position
|
||
const { regionUnderCursor } = detectRegions(tapContainerX, tapContainerY)
|
||
|
||
if (regionUnderCursor && !celebration) {
|
||
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
||
if (region) {
|
||
handleRegionClickWithCelebration(regionUnderCursor, region.name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
[
|
||
detectRegions,
|
||
mapData.regions,
|
||
handleRegionClickWithCelebration,
|
||
celebration,
|
||
displayViewBox,
|
||
zoomSpring,
|
||
]
|
||
)
|
||
|
||
// Helper to select the region at the crosshairs (center of magnifier view)
|
||
const selectRegionAtCrosshairs = useCallback(() => {
|
||
if (!cursorPositionRef.current || !svgRef.current || !containerRef.current) return
|
||
|
||
// Run region detection at the current cursor position (center of magnifier)
|
||
const { regionUnderCursor } = detectRegions(
|
||
cursorPositionRef.current.x,
|
||
cursorPositionRef.current.y
|
||
)
|
||
|
||
if (regionUnderCursor && !celebration) {
|
||
const region = mapData.regions.find((r) => r.id === regionUnderCursor)
|
||
if (region) {
|
||
handleRegionClickWithCelebration(regionUnderCursor, region.name)
|
||
}
|
||
}
|
||
|
||
// Dismiss magnifier after selection attempt
|
||
dismissMagnifier()
|
||
}, [detectRegions, mapData.regions, handleRegionClickWithCelebration, celebration, dismissMagnifier])
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
data-component="map-renderer"
|
||
onMouseMove={handleMouseMove}
|
||
onMouseLeave={handleMouseLeave}
|
||
onClick={handleContainerClick}
|
||
onTouchStart={handleMapTouchStart}
|
||
onTouchMove={handleMapTouchMove}
|
||
onTouchEnd={handleMapTouchEnd}
|
||
className={css({
|
||
position: fillContainer ? 'absolute' : 'relative',
|
||
top: fillContainer ? 0 : undefined,
|
||
left: fillContainer ? 0 : undefined,
|
||
right: fillContainer ? 0 : undefined,
|
||
bottom: fillContainer ? 0 : undefined,
|
||
width: '100%',
|
||
height: '100%',
|
||
flex: fillContainer ? undefined : 1, // Fill available space in parent flex container
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
// Prevent text selection during drag operations
|
||
userSelect: 'none',
|
||
// Disable all default touch gestures - we handle touch events ourselves
|
||
touchAction: 'none',
|
||
// Prevent pull-to-refresh on mobile
|
||
overscrollBehavior: 'none',
|
||
})}
|
||
style={{
|
||
// Vendor-prefixed properties for text selection prevention (not supported in Panda CSS)
|
||
WebkitUserSelect: 'none',
|
||
WebkitTouchCallout: 'none',
|
||
// Sea/ocean background with wavy CSS pattern at screen pixel scale
|
||
backgroundColor: isDark ? '#1e3a5f' : '#a8d4f0',
|
||
backgroundImage: isDark
|
||
? `repeating-linear-gradient(
|
||
15deg,
|
||
transparent,
|
||
transparent 18px,
|
||
rgba(45, 74, 111, 0.5) 18px,
|
||
rgba(45, 74, 111, 0.5) 20px,
|
||
transparent 20px,
|
||
transparent 48px,
|
||
rgba(45, 74, 111, 0.4) 48px,
|
||
rgba(45, 74, 111, 0.4) 50px,
|
||
transparent 50px,
|
||
transparent 78px,
|
||
rgba(45, 74, 111, 0.3) 78px,
|
||
rgba(45, 74, 111, 0.3) 80px
|
||
)`
|
||
: `repeating-linear-gradient(
|
||
15deg,
|
||
transparent,
|
||
transparent 18px,
|
||
rgba(143, 196, 232, 0.5) 18px,
|
||
rgba(143, 196, 232, 0.5) 20px,
|
||
transparent 20px,
|
||
transparent 48px,
|
||
rgba(143, 196, 232, 0.4) 48px,
|
||
rgba(143, 196, 232, 0.4) 50px,
|
||
transparent 50px,
|
||
transparent 78px,
|
||
rgba(143, 196, 232, 0.3) 78px,
|
||
rgba(143, 196, 232, 0.3) 80px
|
||
)`,
|
||
backgroundSize: '100px 100px',
|
||
}}
|
||
>
|
||
<animated.svg
|
||
ref={svgRef}
|
||
viewBox={displayViewBox}
|
||
className={css({
|
||
// Fill the entire container - viewBox controls what portion of map is visible
|
||
width: '100%',
|
||
height: '100%',
|
||
cursor: pointerLocked ? 'crosshair' : 'pointer',
|
||
transformOrigin: 'center center',
|
||
})}
|
||
style={{
|
||
// No aspectRatio - the SVG fills the container and viewBox is calculated
|
||
// to match the container's aspect ratio via calculateFitCropViewBox
|
||
// CSS transform for zoom animation during give-up reveal
|
||
transform: to(
|
||
[mainMapSpring.scale, mainMapSpring.translateX, mainMapSpring.translateY],
|
||
(s, tx, ty) => `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
|
||
const isBeingHinted = hintActive?.regionId === region.id
|
||
const isCelebrating = celebration?.regionId === region.id
|
||
|
||
// Special styling for excluded regions (grayed out, pre-labeled)
|
||
// Bright gold flash for give up reveal, celebration, and hint
|
||
const fill = isCelebrating
|
||
? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})` // Bright gold celebration flash
|
||
: 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/celebrating region gets a prominent stroke
|
||
// Unfound regions get thicker borders for better visibility against sea
|
||
const stroke = isCelebrating
|
||
? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})` // Gold stroke for celebration
|
||
: isBeingRevealed
|
||
? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})` // Orange stroke for contrast
|
||
: getRegionStroke(isFound, isDark)
|
||
const strokeWidth = isCelebrating ? 4 : isBeingRevealed ? 3 : isFound ? 1 : 1.5
|
||
|
||
// Check if a network cursor is hovering over this region
|
||
const networkHover = networkHoveredRegions[region.id]
|
||
|
||
return (
|
||
<g key={region.id} style={{ opacity: dimmedOpacity }}>
|
||
{/* Glow effect for network-hovered region (other player's cursor) */}
|
||
{networkHover && !isBeingRevealed && (
|
||
<path
|
||
d={region.path}
|
||
fill="none"
|
||
stroke={networkHover.color}
|
||
strokeWidth={6}
|
||
vectorEffect="non-scaling-stroke"
|
||
opacity={0.5}
|
||
style={{ filter: 'blur(3px)' }}
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
{/* Glow effect for revealed region */}
|
||
{isBeingRevealed && (
|
||
<path
|
||
d={region.path}
|
||
fill="none"
|
||
stroke={`rgba(255, 215, 0, ${0.3 + giveUpFlashProgress * 0.5})`}
|
||
strokeWidth={8}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ filter: 'blur(4px)' }}
|
||
/>
|
||
)}
|
||
{/* Glow effect for hint - cyan pulsing outline */}
|
||
{isBeingHinted && (
|
||
<path
|
||
d={region.path}
|
||
fill={`rgba(0, 200, 255, ${0.1 + hintFlashProgress * 0.3})`}
|
||
stroke={`rgba(0, 200, 255, ${0.4 + hintFlashProgress * 0.6})`}
|
||
strokeWidth={6}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ filter: 'blur(3px)' }}
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
{/* Glow effect for celebration - bright gold pulsing */}
|
||
{isCelebrating && (
|
||
<path
|
||
d={region.path}
|
||
fill={`rgba(255, 215, 0, ${0.2 + celebrationFlashProgress * 0.4})`}
|
||
stroke={`rgba(255, 215, 0, ${0.4 + celebrationFlashProgress * 0.6})`}
|
||
strokeWidth={10}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ filter: 'blur(6px)' }}
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
{/* Network hover border (crisp outline in player color) */}
|
||
{networkHover && !isBeingRevealed && (
|
||
<path
|
||
d={region.path}
|
||
fill="none"
|
||
stroke={networkHover.color}
|
||
strokeWidth={2}
|
||
vectorEffect="non-scaling-stroke"
|
||
opacity={0.8}
|
||
strokeDasharray="4,2"
|
||
pointerEvents="none"
|
||
/>
|
||
)}
|
||
{/* Region path */}
|
||
<path
|
||
data-region-id={region.id}
|
||
d={region.path}
|
||
fill={fill}
|
||
stroke={stroke}
|
||
strokeWidth={strokeWidth}
|
||
vectorEffect="non-scaling-stroke"
|
||
opacity={showOutline(region) ? 1 : 0.7} // Increased from 0.3 to 0.7 for better visibility
|
||
// When pointer lock is active, hover is controlled by cursor position tracking
|
||
// Otherwise, use native mouse events
|
||
onMouseEnter={() => !isExcluded && !pointerLocked && setHoveredRegion(region.id)}
|
||
onMouseLeave={() => !pointerLocked && setHoveredRegion(null)}
|
||
onClick={() => {
|
||
if (!isExcluded && !celebration) {
|
||
handleRegionClickWithCelebration(region.id, region.name)
|
||
}
|
||
}} // Disable clicks on excluded regions and during celebration
|
||
style={{
|
||
cursor: isExcluded ? 'default' : 'pointer',
|
||
transition: 'all 0.2s ease',
|
||
// Ensure entire path interior is clickable, not just visible fill
|
||
pointerEvents: isExcluded ? 'none' : 'all',
|
||
}}
|
||
/>
|
||
|
||
{/* Ghost element for region center position tracking */}
|
||
<circle
|
||
cx={region.center[0]}
|
||
cy={region.center[1]}
|
||
r={0.1}
|
||
fill="none"
|
||
pointerEvents="none"
|
||
data-ghost-region={region.id}
|
||
/>
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{/* Debug: Render bounding boxes (only if enabled) */}
|
||
{effectiveShowDebugBoundingBoxes &&
|
||
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 (
|
||
<g key={`bbox-${bbox.regionId}`}>
|
||
<rect
|
||
x={bbox.x}
|
||
y={bbox.y}
|
||
width={bbox.width}
|
||
height={bbox.height}
|
||
fill={fillColor}
|
||
stroke={strokeColor}
|
||
strokeWidth={viewBoxWidth / 500}
|
||
vectorEffect="non-scaling-stroke"
|
||
strokeDasharray="3,3"
|
||
pointerEvents="none"
|
||
opacity={0.9}
|
||
/>
|
||
</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={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
|
||
// Account for preserveAspectRatio letterboxing
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
// Calculate leftover dimensions for magnifier sizing
|
||
const leftoverW =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverH =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width: magnifiedWidth } = getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
zoom,
|
||
leftoverW,
|
||
leftoverH
|
||
)
|
||
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
|
||
// Account for preserveAspectRatio letterboxing
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
||
// Calculate leftover dimensions for magnifier sizing
|
||
const leftoverW =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverH =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { height: magnifiedHeight } = getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
zoom,
|
||
leftoverW,
|
||
leftoverH
|
||
)
|
||
return cursorSvgY - magnifiedHeight / 2
|
||
})}
|
||
width={zoomSpring.to((zoom: number) => {
|
||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||
const viewBoxWidth = viewBoxParts[2] || 1000
|
||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||
// Calculate leftover dimensions for magnifier sizing
|
||
const leftoverW =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverH =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width } = getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
zoom,
|
||
leftoverW,
|
||
leftoverH
|
||
)
|
||
return width
|
||
})}
|
||
height={zoomSpring.to((zoom: number) => {
|
||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||
const viewBoxWidth = viewBoxParts[2] || 1000
|
||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||
// Calculate leftover dimensions for magnifier sizing
|
||
const leftoverW =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverH =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { height } = getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
zoom,
|
||
leftoverW,
|
||
leftoverH
|
||
)
|
||
return height
|
||
})}
|
||
fill="none"
|
||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
||
strokeWidth={viewBoxWidth / 500}
|
||
vectorEffect="non-scaling-stroke"
|
||
strokeDasharray="5,5"
|
||
pointerEvents="none"
|
||
opacity={0.8}
|
||
/>
|
||
)}
|
||
</animated.svg>
|
||
|
||
{/* HTML labels positioned absolutely over the SVG */}
|
||
{labelPositions.map((label) => {
|
||
const labelOpacity = calculateLabelOpacity(
|
||
label.x,
|
||
label.y,
|
||
label.regionId,
|
||
cursorPosition,
|
||
hoveredRegion,
|
||
regionsFound,
|
||
isGiveUpAnimating
|
||
)
|
||
return (
|
||
<div
|
||
key={label.regionId}
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${label.x}px`,
|
||
top: `${label.y}px`,
|
||
transform: 'translate(-50%, -50%)',
|
||
pointerEvents: 'none',
|
||
zIndex: 10,
|
||
opacity: labelOpacity,
|
||
transition: 'opacity 0.15s ease-out',
|
||
}}
|
||
>
|
||
{/* 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) => {
|
||
const labelOpacity = calculateLabelOpacity(
|
||
label.labelX,
|
||
label.labelY,
|
||
label.regionId,
|
||
cursorPosition,
|
||
hoveredRegion,
|
||
regionsFound,
|
||
isGiveUpAnimating
|
||
)
|
||
return (
|
||
<div
|
||
key={`small-${label.regionId}`}
|
||
style={{
|
||
opacity: labelOpacity,
|
||
transition: 'opacity 0.15s ease-out',
|
||
}}
|
||
>
|
||
{/* 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={() =>
|
||
!celebration && handleRegionClickWithCelebration(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>
|
||
)
|
||
})}
|
||
|
||
{/* Debug: Bounding box labels as HTML overlays */}
|
||
{effectiveShowDebugBoundingBoxes &&
|
||
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 (accounting for preserveAspectRatio)
|
||
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 viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
|
||
// Convert bbox center from SVG coords to pixels
|
||
const centerX = (bbox.x + bbox.width / 2 - viewBoxX) * viewport.scale + svgOffsetX
|
||
const centerY = (bbox.y + bbox.height / 2 - viewBoxY) * viewport.scale + svgOffsetY
|
||
|
||
return (
|
||
<div
|
||
key={`bbox-label-${bbox.regionId}`}
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${centerX}px`,
|
||
top: `${centerY}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',
|
||
}}
|
||
>
|
||
<div>{bbox.regionId}</div>
|
||
<div style={{ fontSize: '8px', fontWeight: 'normal' }}>{importance.toFixed(2)}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Custom Cursor - Visible when pointer lock is active */}
|
||
{pointerLocked && cursorPosition && (
|
||
<>
|
||
<div
|
||
data-element="custom-cursor"
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${cursorPosition.x}px`,
|
||
top: `${cursorPosition.y}px`,
|
||
width: '20px',
|
||
height: '20px',
|
||
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
|
||
borderRadius: '50%',
|
||
pointerEvents: 'none',
|
||
zIndex: 200,
|
||
transform: `translate(-50%, -50%) scale(${cursorSquish.x}, ${cursorSquish.y})`,
|
||
backgroundColor: 'transparent',
|
||
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
|
||
transition: 'transform 0.1s ease-out', // Smooth squish animation
|
||
}}
|
||
>
|
||
{/* Crosshair - Vertical line */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: '50%',
|
||
top: '0',
|
||
width: '2px',
|
||
height: '100%',
|
||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
||
transform: 'translateX(-50%)',
|
||
}}
|
||
/>
|
||
{/* Crosshair - Horizontal line */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: '0',
|
||
top: '50%',
|
||
width: '100%',
|
||
height: '2px',
|
||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
||
transform: 'translateY(-50%)',
|
||
}}
|
||
/>
|
||
</div>
|
||
{/* Cursor region name label - shows what to find under the cursor */}
|
||
{currentRegionName && (
|
||
<div
|
||
data-element="cursor-region-label"
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${cursorPosition.x}px`,
|
||
top: `${cursorPosition.y + 18}px`,
|
||
transform: 'translateX(-50%)',
|
||
pointerEvents: 'none',
|
||
zIndex: 201,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
padding: '4px 8px',
|
||
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.95)' : 'rgba(219, 234, 254, 0.95)',
|
||
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
|
||
borderRadius: '6px',
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{/* Hot/cold feedback emoji - shows temperature when enabled */}
|
||
{effectiveHotColdEnabled && hotColdFeedbackType && (
|
||
<span
|
||
style={{
|
||
fontSize: '14px',
|
||
marginRight: '2px',
|
||
}}
|
||
title={`Hot/cold: ${hotColdFeedbackType}`}
|
||
>
|
||
{getHotColdEmoji(hotColdFeedbackType)}
|
||
</span>
|
||
)}
|
||
<span
|
||
style={{
|
||
fontSize: '10px',
|
||
fontWeight: 'bold',
|
||
color: isDark ? '#93c5fd' : '#1e40af',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px',
|
||
}}
|
||
>
|
||
Find
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: '13px',
|
||
fontWeight: 'bold',
|
||
color: isDark ? 'white' : '#1e3a8a',
|
||
}}
|
||
>
|
||
{currentRegionName}
|
||
</span>
|
||
{currentFlagEmoji && <span style={{ fontSize: '14px' }}>{currentFlagEmoji}</span>}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Magnifier overlay - centers on cursor position */}
|
||
{(() => {
|
||
if (!cursorPosition || !svgRef.current || !containerRef.current) {
|
||
return null
|
||
}
|
||
|
||
// Calculate magnifier size based on leftover rectangle (area not covered by UI)
|
||
const containerRect = containerRef.current.getBoundingClientRect()
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width: magnifierWidthPx, height: magnifierHeightPx } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
return (
|
||
<animated.div
|
||
ref={magnifierRef}
|
||
data-element="magnifier"
|
||
onTouchStart={handleMagnifierTouchStart}
|
||
onTouchMove={handleMagnifierTouchMove}
|
||
onTouchEnd={handleMagnifierTouchEnd}
|
||
onTouchCancel={handleMagnifierTouchEnd}
|
||
style={{
|
||
position: 'absolute',
|
||
// Animated positioning - smoothly moves to opposite corner from cursor
|
||
top: magnifierSpring.top,
|
||
left: magnifierSpring.left,
|
||
width: magnifierWidthPx,
|
||
height: magnifierHeightPx,
|
||
// High zoom (>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',
|
||
// Enable touch events on mobile for panning, but keep mouse events disabled
|
||
// This allows touch-based panning while not interfering with mouse-based interactions
|
||
pointerEvents: 'auto',
|
||
touchAction: 'none', // Prevent browser handling of touch gestures
|
||
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,
|
||
}}
|
||
>
|
||
<animated.svg
|
||
viewBox={zoomSpring.to((zoom: number) => {
|
||
// Calculate magnified viewBox centered on cursor
|
||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||
const svgRect = svgRef.current!.getBoundingClientRect()
|
||
|
||
// Convert cursor position to SVG coordinates (accounting for preserveAspectRatio)
|
||
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 viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
|
||
// Center position relative to SVG (uses reveal center during give-up animation)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
||
|
||
// Magnified view: adjust dimensions to match magnifier container aspect ratio
|
||
// This eliminates letterboxing and ensures outline matches what's visible
|
||
// Use leftover dimensions for magnifier sizing
|
||
const leftoverW =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverH =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width: magnifiedWidth, height: magnifiedHeight } =
|
||
getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
zoom,
|
||
leftoverW,
|
||
leftoverH
|
||
)
|
||
|
||
// 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'
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
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 (
|
||
<rect
|
||
x={viewBoxParts[0] || 0}
|
||
y={viewBoxParts[1] || 0}
|
||
width={viewBoxParts[2] || 1000}
|
||
height={viewBoxParts[3] || 1000}
|
||
fill={isDark ? '#1e3a5f' : '#a8d4f0'}
|
||
/>
|
||
)
|
||
})()}
|
||
|
||
{/* 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
|
||
const isCelebrating = celebration?.regionId === region.id
|
||
|
||
// Bright gold flash for celebration and give up reveal in magnifier too
|
||
const fill = isCelebrating
|
||
? `rgba(255, 215, 0, ${0.7 + celebrationFlashProgress * 0.3})`
|
||
: 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/celebrating region gets a prominent stroke
|
||
// Unfound regions get thicker borders for better visibility against sea
|
||
const stroke = isCelebrating
|
||
? `rgba(255, 180, 0, ${0.8 + celebrationFlashProgress * 0.2})`
|
||
: isBeingRevealed
|
||
? `rgba(255, 140, 0, ${0.8 + giveUpFlashProgress * 0.2})`
|
||
: getRegionStroke(isFound, isDark)
|
||
const strokeWidth = isCelebrating ? 3 : isBeingRevealed ? 2 : isFound ? 0.5 : 1
|
||
|
||
return (
|
||
<g key={`mag-${region.id}`} style={{ opacity: dimmedOpacity }}>
|
||
{/* Glow effect for revealed region in magnifier */}
|
||
{isBeingRevealed && (
|
||
<path
|
||
d={region.path}
|
||
fill="none"
|
||
stroke={`rgba(255, 215, 0, ${0.3 + giveUpFlashProgress * 0.5})`}
|
||
strokeWidth={5}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ filter: 'blur(2px)' }}
|
||
/>
|
||
)}
|
||
{/* Glow effect for celebrating region in magnifier */}
|
||
{isCelebrating && (
|
||
<path
|
||
d={region.path}
|
||
fill={`rgba(255, 215, 0, ${0.2 + celebrationFlashProgress * 0.4})`}
|
||
stroke={`rgba(255, 215, 0, ${0.4 + celebrationFlashProgress * 0.6})`}
|
||
strokeWidth={8}
|
||
vectorEffect="non-scaling-stroke"
|
||
style={{ filter: 'blur(4px)' }}
|
||
/>
|
||
)}
|
||
<path
|
||
d={region.path}
|
||
fill={fill}
|
||
stroke={stroke}
|
||
strokeWidth={strokeWidth}
|
||
vectorEffect="non-scaling-stroke"
|
||
opacity={showOutline(region) ? 1 : 0.3}
|
||
/>
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{/* Crosshair at center position (cursor or reveal center during animation) */}
|
||
<g>
|
||
{(() => {
|
||
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
|
||
// Account for preserveAspectRatio letterboxing
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + 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>
|
||
|
||
{/* 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
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
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)
|
||
}
|
||
|
||
// Account for preserveAspectRatio letterboxing
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
|
||
// Calculate grid spacing in SVG units
|
||
// Each grid cell represents one screen pixel of mouse movement on the main map
|
||
const mainMapSvgUnitsPerScreenPixel = 1 / viewport.scale
|
||
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 svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + 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 (
|
||
<g data-element="pixel-grid-overlay">
|
||
{lines.map((line, i) =>
|
||
line.type === 'v' ? (
|
||
<line
|
||
key={`vgrid-${i}`}
|
||
x1={line.pos}
|
||
y1={gridTop}
|
||
x2={line.pos}
|
||
y2={gridBottom}
|
||
stroke={gridColor}
|
||
strokeWidth={viewBoxWidth / 2000}
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
) : (
|
||
<line
|
||
key={`hgrid-${i}`}
|
||
x1={gridLeft}
|
||
y1={line.pos}
|
||
x2={gridRight}
|
||
y2={line.pos}
|
||
stroke={gridColor}
|
||
strokeWidth={viewBoxWidth / 2000}
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
)
|
||
)}
|
||
</g>
|
||
)
|
||
})()}
|
||
|
||
{/* Debug: Bounding boxes for detected regions in magnifier */}
|
||
{effectiveShowDebugBoundingBoxes &&
|
||
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 (
|
||
<rect
|
||
key={`mag-bbox-${bbox.regionId}`}
|
||
x={bbox.x}
|
||
y={bbox.y}
|
||
width={bbox.width}
|
||
height={bbox.height}
|
||
fill="none"
|
||
stroke={strokeColor}
|
||
strokeWidth={1}
|
||
vectorEffect="non-scaling-stroke"
|
||
pointerEvents="none"
|
||
/>
|
||
)
|
||
})}
|
||
</animated.svg>
|
||
|
||
{/* Debug: Bounding box labels as HTML overlays - positioned using animated values */}
|
||
{effectiveShowDebugBoundingBoxes &&
|
||
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 (
|
||
<animated.div
|
||
key={`mag-bbox-label-${bbox.regionId}`}
|
||
style={{
|
||
position: 'absolute',
|
||
// Calculate position using the same spring that controls the magnifier viewBox
|
||
left: zoomSpring.to((zoom: number) => {
|
||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||
const svgRect = svgRef.current?.getBoundingClientRect()
|
||
if (!containerRect || !svgRect || !cursorPosition) return '-9999px'
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
// Magnifier dimensions based on leftover rectangle
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
// Convert cursor to SVG coordinates (accounting for preserveAspectRatio)
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const cursorSvgX =
|
||
(cursorPosition.x - svgOffsetX) / viewport.scale + 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'
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
// Magnifier dimensions based on leftover rectangle
|
||
const { width: magnifierWidth, height: magnifierHeight } =
|
||
getMagnifierDimensions(leftoverWidth, leftoverHeight)
|
||
|
||
// Convert cursor to SVG coordinates (accounting for preserveAspectRatio)
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const cursorSvgY =
|
||
(cursorPosition.y - svgOffsetY) / viewport.scale + 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',
|
||
}}
|
||
>
|
||
<div>{bbox.regionId}</div>
|
||
<div style={{ fontSize: '8px', fontWeight: 'normal' }}>
|
||
{importance.toFixed(2)}
|
||
</div>
|
||
</animated.div>
|
||
)
|
||
})}
|
||
|
||
{/* 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: pointerLocked ? 'none' : 'auto',
|
||
cursor: pointerLocked ? 'default' : 'pointer',
|
||
}}
|
||
onClick={(e) => {
|
||
// 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)}×`
|
||
}
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
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 (effectiveShowMagnifierDebugInfo) {
|
||
return `${z.toFixed(1)}× | ${screenPixelRatio.toFixed(1)} px/px`
|
||
}
|
||
|
||
return `${z.toFixed(1)}×`
|
||
})}
|
||
</animated.div>
|
||
|
||
{/* 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
|
||
|
||
// Calculate leftover rectangle dimensions
|
||
const leftoverWidth =
|
||
containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
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 (
|
||
<div
|
||
data-element="precision-mode-scrim"
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
background: 'rgba(251, 191, 36, 0.15)', // Gold scrim
|
||
pointerEvents: 'none',
|
||
borderRadius: '12px',
|
||
}}
|
||
/>
|
||
)
|
||
})()}
|
||
</animated.div>
|
||
)
|
||
})()}
|
||
|
||
{/* Mobile Select button - appears when magnifier is visible but not being dragged */}
|
||
{showMagnifier && !isMobileMapDragging && !isMagnifierDragging && cursorPosition && (
|
||
<animated.button
|
||
data-action="mobile-select-region"
|
||
type="button"
|
||
onClick={selectRegionAtCrosshairs}
|
||
onTouchEnd={(e) => {
|
||
e.stopPropagation() // Prevent triggering map touch end
|
||
selectRegionAtCrosshairs()
|
||
}}
|
||
style={{
|
||
position: 'absolute',
|
||
// Position below the magnifier
|
||
top: magnifierSpring.top.to((t) => {
|
||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||
if (!containerRect) return t + 200
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { height: magnifierHeight } = getMagnifierDimensions(leftoverWidth, leftoverHeight)
|
||
return t + magnifierHeight + 12 // 12px gap below magnifier
|
||
}),
|
||
left: magnifierSpring.left.to((l) => {
|
||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||
if (!containerRect) return l
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
const { width: magnifierWidth } = getMagnifierDimensions(leftoverWidth, leftoverHeight)
|
||
return l + magnifierWidth / 2 - 60 // Center the 120px button under magnifier
|
||
}),
|
||
width: 120,
|
||
opacity: magnifierSpring.opacity,
|
||
zIndex: 101,
|
||
}}
|
||
className={css({
|
||
padding: '12px 24px',
|
||
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
|
||
border: 'none',
|
||
borderRadius: '12px',
|
||
color: 'white',
|
||
fontSize: '16px',
|
||
fontWeight: 'bold',
|
||
cursor: 'pointer',
|
||
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.4)',
|
||
touchAction: 'none',
|
||
_active: {
|
||
transform: 'scale(0.95)',
|
||
},
|
||
})}
|
||
>
|
||
Select ✓
|
||
</animated.button>
|
||
)}
|
||
|
||
{/* 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()
|
||
|
||
// Calculate leftover rectangle dimensions (area not covered by UI elements)
|
||
const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right
|
||
const leftoverHeight =
|
||
containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom
|
||
|
||
// Get magnifier dimensions based on leftover rectangle (responsive to its aspect ratio)
|
||
const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions(
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
// 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()
|
||
// Use adjusted dimensions to match magnifier aspect ratio
|
||
const { width: indicatorWidth, height: indicatorHeight } = getAdjustedMagnifiedDimensions(
|
||
viewBoxWidth,
|
||
viewBoxHeight,
|
||
currentZoom,
|
||
leftoverWidth,
|
||
leftoverHeight
|
||
)
|
||
|
||
// Convert cursor to SVG coordinates (accounting for preserveAspectRatio)
|
||
const viewport = getRenderedViewport(
|
||
svgRect,
|
||
viewBoxX,
|
||
viewBoxY,
|
||
viewBoxWidth,
|
||
viewBoxHeight
|
||
)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
|
||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + 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) * viewport.scale + svgOffsetX,
|
||
y: (svgY - viewBoxY) * viewport.scale + 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 (
|
||
<svg
|
||
data-element="zoom-lines"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
pointerEvents: 'none',
|
||
zIndex: 99, // Just below magnifier (100)
|
||
overflow: 'visible',
|
||
}}
|
||
>
|
||
<defs>
|
||
{/* Gradient for lines - fades toward magnifier */}
|
||
<linearGradient id="zoom-line-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||
<stop offset="0%" stopColor={lineColor} stopOpacity="0.8" />
|
||
<stop offset="40%" stopColor={lineColor} stopOpacity="0.5" />
|
||
<stop offset="100%" stopColor={lineColor} stopOpacity="0.2" />
|
||
</linearGradient>
|
||
|
||
{/* Glow filter for premium effect */}
|
||
<filter id="zoom-line-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||
<feMerge>
|
||
<feMergeNode in="blur" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
|
||
{/* Animated dash pattern */}
|
||
<pattern id="dash-pattern" patternUnits="userSpaceOnUse" width="12" height="1">
|
||
<rect width="8" height="1" fill={lineColor} opacity="0.6" />
|
||
</pattern>
|
||
</defs>
|
||
|
||
{/* Glow layer (underneath) */}
|
||
<g filter="url(#zoom-line-glow)" opacity={0.4}>
|
||
{paths.map((d, i) => (
|
||
<path
|
||
key={`glow-${i}`}
|
||
d={d}
|
||
fill="none"
|
||
stroke={glowColor}
|
||
strokeWidth="6"
|
||
strokeLinecap="round"
|
||
/>
|
||
))}
|
||
</g>
|
||
|
||
{/* Main lines with gradient */}
|
||
<g opacity={targetOpacity}>
|
||
{paths.map((d, i) => (
|
||
<path
|
||
key={`line-${i}`}
|
||
d={d}
|
||
fill="none"
|
||
stroke="url(#zoom-line-gradient)"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
style={{
|
||
// Subtle animation for the lines
|
||
strokeDasharray: '8 4',
|
||
strokeDashoffset: '0',
|
||
animation: 'zoom-line-flow 1s linear infinite',
|
||
}}
|
||
/>
|
||
))}
|
||
</g>
|
||
|
||
{/* Corner dots on indicator for visible lines only */}
|
||
<g opacity={targetOpacity * 0.8}>
|
||
{visibleCorners.map((corner, i) => (
|
||
<circle
|
||
key={`corner-${i}`}
|
||
cx={corner.x}
|
||
cy={corner.y}
|
||
r="3"
|
||
fill={lineColor}
|
||
opacity="0.7"
|
||
/>
|
||
))}
|
||
</g>
|
||
|
||
<style>
|
||
{`
|
||
@keyframes zoom-line-flow {
|
||
from { stroke-dashoffset: 12; }
|
||
to { stroke-dashoffset: 0; }
|
||
}
|
||
`}
|
||
</style>
|
||
</svg>
|
||
)
|
||
})()}
|
||
|
||
{/* Debug: Auto zoom detection visualization (dev only) */}
|
||
{effectiveShowMagnifierDebugInfo && cursorPosition && containerRef.current && (
|
||
<>
|
||
{/* Detection box - 50px box around cursor */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${cursorPosition.x - 25}px`,
|
||
top: `${cursorPosition.y - 25}px`,
|
||
width: '50px',
|
||
height: '50px',
|
||
border: '2px dashed yellow',
|
||
pointerEvents: 'none',
|
||
zIndex: 150,
|
||
}}
|
||
/>
|
||
|
||
{/* Detection info overlay - opposite side from magnifier */}
|
||
{(() => {
|
||
const { detectedRegions, hasSmallRegion, detectedSmallestSize } = detectRegions(
|
||
cursorPosition.x,
|
||
cursorPosition.y
|
||
)
|
||
|
||
// Position on opposite side from magnifier
|
||
const containerWidth = containerRef.current?.getBoundingClientRect().width ?? 0
|
||
const magnifierOnLeft = targetLeft < containerWidth / 2
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: '10px',
|
||
left: magnifierOnLeft ? undefined : '10px',
|
||
right: magnifierOnLeft ? '10px' : undefined,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
color: 'white',
|
||
padding: '10px',
|
||
borderRadius: '4px',
|
||
fontSize: '12px',
|
||
fontFamily: 'monospace',
|
||
pointerEvents: 'none',
|
||
zIndex: 150,
|
||
maxWidth: '300px',
|
||
}}
|
||
>
|
||
<div>
|
||
<strong>Detection Box (50px)</strong>
|
||
</div>
|
||
<div>Regions detected: {detectedRegions.length}</div>
|
||
<div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div>
|
||
<div>
|
||
Smallest size:{' '}
|
||
{detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
|
||
</div>
|
||
{/* Zoom Decision Details */}
|
||
{zoomSearchDebugInfo && (
|
||
<>
|
||
<div
|
||
style={{
|
||
marginTop: '8px',
|
||
paddingTop: '8px',
|
||
borderTop: '1px solid #444',
|
||
}}
|
||
>
|
||
<strong>Zoom Decision:</strong>
|
||
</div>
|
||
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
|
||
Final zoom: <strong>{zoomSearchDebugInfo.zoom.toFixed(1)}×</strong>
|
||
{!zoomSearchDebugInfo.foundGoodZoom && ' (fallback to min)'}
|
||
</div>
|
||
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
|
||
Accepted: <strong>{zoomSearchDebugInfo.acceptedRegionId || 'none'}</strong>
|
||
</div>
|
||
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
|
||
Thresholds: {(zoomSearchDebugInfo.acceptanceThresholds.min * 100).toFixed(0)}%
|
||
- {(zoomSearchDebugInfo.acceptanceThresholds.max * 100).toFixed(0)}% of
|
||
magnifier
|
||
</div>
|
||
|
||
<div style={{ marginTop: '8px' }}>
|
||
<strong>Region Analysis (top 3):</strong>
|
||
</div>
|
||
{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 (
|
||
<div
|
||
key={`decision-${decision.regionId}`}
|
||
style={{
|
||
fontSize: '9px',
|
||
marginLeft: '8px',
|
||
color,
|
||
}}
|
||
>
|
||
{marker} {decision.regionId}: {decision.currentSize.width.toFixed(0)}×
|
||
{decision.currentSize.height.toFixed(0)}px
|
||
{decision.rejectionReason && ` (${decision.rejectionReason})`}
|
||
</div>
|
||
)
|
||
})}
|
||
</>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
marginTop: '8px',
|
||
paddingTop: '8px',
|
||
borderTop: '1px solid #444',
|
||
}}
|
||
>
|
||
<strong>Detected Regions ({detectedRegions.length}):</strong>
|
||
</div>
|
||
{detectedRegions.map((region) => (
|
||
<div key={region.id} style={{ fontSize: '10px', marginLeft: '8px' }}>
|
||
• {region.id}: {region.pixelWidth.toFixed(1)}×{region.pixelHeight.toFixed(1)}px
|
||
{region.isVerySmall ? ' (SMALL)' : ''}
|
||
</div>
|
||
))}
|
||
<div style={{ marginTop: '8px' }}>
|
||
<strong>Current Zoom:</strong> {getCurrentZoom().toFixed(1)}×
|
||
</div>
|
||
<div>
|
||
<strong>Target Zoom:</strong> {targetZoom.toFixed(1)}×
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</>
|
||
)}
|
||
|
||
{/* 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 (accounting for preserveAspectRatio letterboxing)
|
||
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 viewport = getRenderedViewport(svgRect, viewBoxX, viewBoxY, viewBoxW, viewBoxH)
|
||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||
const screenX = (position.x - viewBoxX) * viewport.scale + svgOffsetX
|
||
const screenY = (position.y - viewBoxY) * viewport.scale + svgOffsetY
|
||
|
||
// Check if cursor is within rendered viewport bounds
|
||
if (
|
||
screenX < svgOffsetX ||
|
||
screenX > svgOffsetX + viewport.renderedWidth ||
|
||
screenY < svgOffsetY ||
|
||
screenY > svgOffsetY + viewport.renderedHeight
|
||
) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={`cursor-${playerId}`}
|
||
data-element="other-player-cursor"
|
||
data-player-id={playerId}
|
||
style={{
|
||
position: 'absolute',
|
||
left: screenX,
|
||
top: screenY,
|
||
pointerEvents: 'none',
|
||
zIndex: 100,
|
||
}}
|
||
>
|
||
{/* Crosshair - centered on the cursor position */}
|
||
<svg
|
||
width="24"
|
||
height="24"
|
||
viewBox="0 0 24 24"
|
||
style={{
|
||
position: 'absolute',
|
||
left: -12, // Half of width to center
|
||
top: -12, // Half of height to center
|
||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
|
||
}}
|
||
>
|
||
{/* Outer ring */}
|
||
<circle
|
||
cx="12"
|
||
cy="12"
|
||
r="8"
|
||
fill="none"
|
||
stroke={player.color}
|
||
strokeWidth="2"
|
||
opacity="0.8"
|
||
/>
|
||
{/* Cross lines */}
|
||
<line
|
||
x1="12"
|
||
y1="2"
|
||
x2="12"
|
||
y2="8"
|
||
stroke={player.color}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
<line
|
||
x1="12"
|
||
y1="16"
|
||
x2="12"
|
||
y2="22"
|
||
stroke={player.color}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
<line
|
||
x1="2"
|
||
y1="12"
|
||
x2="8"
|
||
y2="12"
|
||
stroke={player.color}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
<line
|
||
x1="16"
|
||
y1="12"
|
||
x2="22"
|
||
y2="12"
|
||
stroke={player.color}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
/>
|
||
{/* Center dot */}
|
||
<circle cx="12" cy="12" r="2" fill={player.color} />
|
||
</svg>
|
||
{/* Player emoji label(s) - positioned below crosshair */}
|
||
{/* In collaborative mode, show all emojis from the same session */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: '50%',
|
||
top: 14, // Below the crosshair (12px half-height + 2px gap)
|
||
transform: 'translateX(-50%)',
|
||
fontSize: '16px',
|
||
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{sessionPlayers.map((p) => p.emoji).join('')}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{/* Dev-only crop tool for getting custom viewBox coordinates */}
|
||
<DevCropTool
|
||
svgRef={svgRef}
|
||
containerRef={containerRef}
|
||
viewBox={displayViewBox}
|
||
mapId={selectedMap}
|
||
continentId={selectedContinent}
|
||
/>
|
||
|
||
{/* Debug overlay showing safe zone rectangles */}
|
||
{effectiveShowSafeZoneDebug &&
|
||
fillContainer &&
|
||
(() => {
|
||
// Calculate the leftover rectangle (viewport minus margins)
|
||
const leftoverRect = {
|
||
left: SAFE_ZONE_MARGINS.left,
|
||
top: SAFE_ZONE_MARGINS.top,
|
||
width: svgDimensions.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right,
|
||
height: svgDimensions.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom,
|
||
}
|
||
|
||
// Calculate where the crop region appears in viewport pixels
|
||
// Using the display viewBox to map SVG coords to pixels
|
||
const viewBox = parseViewBox(displayViewBox)
|
||
|
||
// Use custom crop if defined, otherwise use the full original map bounds (same as displayViewBox logic)
|
||
const originalBounds = parseViewBox(mapData.originalViewBox)
|
||
const cropRegion = mapData.customCrop ? parseViewBox(mapData.customCrop) : originalBounds
|
||
const isCustomCrop = !!mapData.customCrop
|
||
|
||
// With preserveAspectRatio="xMidYMid meet", the SVG is letterboxed
|
||
// Calculate the actual scale and offset
|
||
const scaleX = svgDimensions.width / viewBox.width
|
||
const scaleY = svgDimensions.height / viewBox.height
|
||
const actualScale = Math.min(scaleX, scaleY) // "meet" uses the smaller scale
|
||
|
||
// Calculate letterbox offsets (the SVG content is centered)
|
||
const renderedWidth = viewBox.width * actualScale
|
||
const renderedHeight = viewBox.height * actualScale
|
||
const offsetX = (svgDimensions.width - renderedWidth) / 2
|
||
const offsetY = (svgDimensions.height - renderedHeight) / 2
|
||
|
||
// SVG point (x, y) -> pixel, accounting for letterboxing
|
||
const svgToPixelX = (x: number) => offsetX + (x - viewBox.x) * actualScale
|
||
const svgToPixelY = (y: number) => offsetY + (y - viewBox.y) * actualScale
|
||
|
||
const cropPixelRect = {
|
||
left: svgToPixelX(cropRegion.x),
|
||
top: svgToPixelY(cropRegion.y),
|
||
width: cropRegion.width * actualScale,
|
||
height: cropRegion.height * actualScale,
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* Leftover rectangle (safe zone where crop should fit) - GREEN */}
|
||
<div
|
||
data-element="debug-leftover-rect"
|
||
style={{
|
||
position: 'absolute',
|
||
left: leftoverRect.left,
|
||
top: leftoverRect.top,
|
||
width: leftoverRect.width,
|
||
height: leftoverRect.height,
|
||
border: '3px dashed rgba(0, 255, 0, 0.8)',
|
||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||
pointerEvents: 'none',
|
||
zIndex: 9999,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
position: 'absolute',
|
||
top: 4,
|
||
left: 4,
|
||
background: 'rgba(0, 255, 0, 0.9)',
|
||
color: 'black',
|
||
padding: '2px 6px',
|
||
fontSize: '11px',
|
||
fontWeight: 'bold',
|
||
borderRadius: '3px',
|
||
}}
|
||
>
|
||
LEFTOVER ({Math.round(leftoverRect.width)}×{Math.round(leftoverRect.height)})
|
||
</span>
|
||
</div>
|
||
|
||
{/* Crop region mapped to pixels - RED for custom, ORANGE for full map */}
|
||
<div
|
||
data-element="debug-crop-rect"
|
||
style={{
|
||
position: 'absolute',
|
||
left: cropPixelRect.left,
|
||
top: cropPixelRect.top,
|
||
width: cropPixelRect.width,
|
||
height: cropPixelRect.height,
|
||
border: `3px ${isCustomCrop ? 'solid' : 'dashed'} ${isCustomCrop ? 'rgba(255, 0, 0, 0.8)' : 'rgba(255, 165, 0, 0.8)'}`,
|
||
backgroundColor: isCustomCrop
|
||
? 'rgba(255, 0, 0, 0.05)'
|
||
: 'rgba(255, 165, 0, 0.05)',
|
||
pointerEvents: 'none',
|
||
zIndex: 9998,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 4,
|
||
right: 4,
|
||
background: isCustomCrop ? 'rgba(255, 0, 0, 0.9)' : 'rgba(255, 165, 0, 0.9)',
|
||
color: isCustomCrop ? 'white' : 'black',
|
||
padding: '2px 6px',
|
||
fontSize: '11px',
|
||
fontWeight: 'bold',
|
||
borderRadius: '3px',
|
||
}}
|
||
>
|
||
{isCustomCrop ? 'CROP' : 'FULL MAP'} ({Math.round(cropPixelRect.width)}×
|
||
{Math.round(cropPixelRect.height)})
|
||
</span>
|
||
</div>
|
||
|
||
{/* Info panel showing calculations */}
|
||
<div
|
||
data-element="debug-safe-zone-info"
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 10,
|
||
left: 10,
|
||
background: 'rgba(0, 0, 0, 0.85)',
|
||
color: 'white',
|
||
padding: '8px 12px',
|
||
fontSize: '11px',
|
||
fontFamily: 'monospace',
|
||
borderRadius: '6px',
|
||
pointerEvents: 'none',
|
||
zIndex: 9999,
|
||
lineHeight: 1.4,
|
||
}}
|
||
>
|
||
<div>
|
||
<strong>Safe Zone Debug</strong>
|
||
</div>
|
||
<div>
|
||
Viewport: {Math.round(svgDimensions.width)}×{Math.round(svgDimensions.height)}
|
||
</div>
|
||
<div>
|
||
Margins: T={SAFE_ZONE_MARGINS.top} R={SAFE_ZONE_MARGINS.right} B=
|
||
{SAFE_ZONE_MARGINS.bottom} L={SAFE_ZONE_MARGINS.left}
|
||
</div>
|
||
<div style={{ color: '#0f0' }}>
|
||
Leftover: {Math.round(leftoverRect.width)}×{Math.round(leftoverRect.height)}
|
||
</div>
|
||
<div style={{ color: isCustomCrop ? '#f00' : '#ffa500' }}>
|
||
{isCustomCrop ? 'Crop' : 'Full Map'} (px): {Math.round(cropPixelRect.width)}×
|
||
{Math.round(cropPixelRect.height)}
|
||
</div>
|
||
<div>ViewBox: {displayViewBox}</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
|
||
{/* Celebration overlay - shows confetti and sound when region is found */}
|
||
{celebration && (
|
||
<CelebrationOverlay
|
||
celebration={celebration}
|
||
regionCenter={getCelebrationRegionCenter()}
|
||
onComplete={handleCelebrationComplete}
|
||
reducedMotion={false}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|