fix(know-your-world): replace react-spring with CSS animation for crosshair rotation
React-spring was lagging 1000+ degrees behind the target rotation value due to internal batching/queueing when called 60fps from requestAnimationFrame while React was also re-rendering from cursor movement. CSS animations run on the browser's compositor thread, completely independent of JavaScript execution and React re-renders, eliminating the wild spinning bug. Key changes: - Remove useSpring and requestAnimationFrame-based rotation loop - Use CSS @keyframes crosshairSpin animation with variable duration - Duration calculated from heat level: 360 / (speed * 60) seconds - animation-play-state controls running/paused state - Debounced shouldRotate state prevents flicker from feedback type flickering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
824325b843
commit
af5e7b59dc
|
|
@ -39,6 +39,7 @@ import type { HintMap } from '../messages'
|
||||||
import { useKnowYourWorld } from '../Provider'
|
import { useKnowYourWorld } from '../Provider'
|
||||||
import type { MapData, MapRegion } from '../types'
|
import type { MapData, MapRegion } from '../types'
|
||||||
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
|
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
|
||||||
|
import { CELEBRATION_TIMING, classifyCelebration } from '../utils/celebration'
|
||||||
import type { FeedbackType } from '../utils/hotColdPhrases'
|
import type { FeedbackType } from '../utils/hotColdPhrases'
|
||||||
import {
|
import {
|
||||||
getAdjustedMagnifiedDimensions,
|
getAdjustedMagnifiedDimensions,
|
||||||
|
|
@ -49,7 +50,6 @@ import {
|
||||||
calculateScreenPixelRatio,
|
calculateScreenPixelRatio,
|
||||||
isAboveThreshold,
|
isAboveThreshold,
|
||||||
} from '../utils/screenPixelRatio'
|
} from '../utils/screenPixelRatio'
|
||||||
import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration'
|
|
||||||
import { CelebrationOverlay } from './CelebrationOverlay'
|
import { CelebrationOverlay } from './CelebrationOverlay'
|
||||||
import { DevCropTool } from './DevCropTool'
|
import { DevCropTool } from './DevCropTool'
|
||||||
|
|
||||||
|
|
@ -184,6 +184,180 @@ function getHeatBorderColors(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert FeedbackType to a numeric heat level (0-1)
|
||||||
|
* Used for continuous effects like rotation speed
|
||||||
|
*/
|
||||||
|
function getHeatLevel(feedbackType: FeedbackType | null): number {
|
||||||
|
switch (feedbackType) {
|
||||||
|
case 'found_it':
|
||||||
|
return 1.0
|
||||||
|
case 'on_fire':
|
||||||
|
return 0.9
|
||||||
|
case 'hot':
|
||||||
|
return 0.75
|
||||||
|
case 'warmer':
|
||||||
|
return 0.6
|
||||||
|
case 'colder':
|
||||||
|
return 0.4
|
||||||
|
case 'cold':
|
||||||
|
return 0.25
|
||||||
|
case 'freezing':
|
||||||
|
return 0.1
|
||||||
|
case 'overshot':
|
||||||
|
return 0.3
|
||||||
|
case 'stuck':
|
||||||
|
return 0.35
|
||||||
|
default:
|
||||||
|
return 0.5 // Neutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate rotation speed based on heat level using 1/x backoff curve
|
||||||
|
* - No rotation below heat 0.5
|
||||||
|
* - Maximum rotation (1 rotation/sec = 6°/frame at 60fps) at heat 1.0
|
||||||
|
* - Uses squared curve for rapid acceleration near found_it
|
||||||
|
*/
|
||||||
|
function getRotationSpeed(heatLevel: number): number {
|
||||||
|
const THRESHOLD = 0.5
|
||||||
|
const MAX_SPEED = 6 // 1 rotation/sec at 60fps (360°/sec / 60 = 6°/frame)
|
||||||
|
|
||||||
|
if (heatLevel <= THRESHOLD) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1/x style backoff: speed increases rapidly as heat approaches 1.0
|
||||||
|
// Using squared curve: ((heat - 0.5) / 0.5)^2 * maxSpeed
|
||||||
|
const normalized = (heatLevel - THRESHOLD) / (1 - THRESHOLD)
|
||||||
|
return MAX_SPEED * normalized * normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get crosshair styling based on hot/cold feedback
|
||||||
|
* Returns color, opacity, rotation speed, and fire state for heat-reactive crosshairs
|
||||||
|
*/
|
||||||
|
function getHeatCrosshairStyle(
|
||||||
|
feedbackType: FeedbackType | null,
|
||||||
|
isDark: boolean,
|
||||||
|
hotColdEnabled: boolean
|
||||||
|
): {
|
||||||
|
color: string
|
||||||
|
opacity: number
|
||||||
|
showFire: boolean
|
||||||
|
rotationSpeed: number // degrees per frame at 60fps (0 = no rotation)
|
||||||
|
glowColor: string
|
||||||
|
strokeWidth: number
|
||||||
|
} {
|
||||||
|
const heatLevel = getHeatLevel(feedbackType)
|
||||||
|
const rotationSpeed = hotColdEnabled ? getRotationSpeed(heatLevel) : 0
|
||||||
|
|
||||||
|
// Default styling when hot/cold not enabled
|
||||||
|
if (!hotColdEnabled || !feedbackType) {
|
||||||
|
return {
|
||||||
|
color: isDark ? '#60a5fa' : '#3b82f6', // Default blue
|
||||||
|
opacity: 1,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed: 0,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (feedbackType) {
|
||||||
|
case 'found_it':
|
||||||
|
return {
|
||||||
|
color: '#fbbf24', // Gold
|
||||||
|
opacity: 1,
|
||||||
|
showFire: true,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'rgba(251, 191, 36, 0.8)',
|
||||||
|
strokeWidth: 3,
|
||||||
|
}
|
||||||
|
case 'on_fire':
|
||||||
|
return {
|
||||||
|
color: '#ef4444', // Bright red
|
||||||
|
opacity: 1,
|
||||||
|
showFire: true, // Show fire particles
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'rgba(239, 68, 68, 0.7)',
|
||||||
|
strokeWidth: 3,
|
||||||
|
}
|
||||||
|
case 'hot':
|
||||||
|
return {
|
||||||
|
color: '#f97316', // Orange
|
||||||
|
opacity: 1,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'rgba(249, 115, 22, 0.5)',
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
}
|
||||||
|
case 'warmer':
|
||||||
|
return {
|
||||||
|
color: '#fb923c', // Light orange
|
||||||
|
opacity: 0.9,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'rgba(251, 146, 60, 0.4)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
case 'colder':
|
||||||
|
return {
|
||||||
|
color: '#93c5fd', // Light blue
|
||||||
|
opacity: 0.6,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
case 'cold':
|
||||||
|
return {
|
||||||
|
color: '#60a5fa', // Blue
|
||||||
|
opacity: 0.4,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
}
|
||||||
|
case 'freezing':
|
||||||
|
return {
|
||||||
|
color: '#38bdf8', // Ice blue/cyan
|
||||||
|
opacity: 0.25, // Very faded
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 1,
|
||||||
|
}
|
||||||
|
case 'overshot':
|
||||||
|
return {
|
||||||
|
color: '#a855f7', // Purple (went past it)
|
||||||
|
opacity: 0.8,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'rgba(168, 85, 247, 0.4)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
case 'stuck':
|
||||||
|
return {
|
||||||
|
color: '#9ca3af', // Gray
|
||||||
|
opacity: 0.5,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: isDark ? '#60a5fa' : '#3b82f6',
|
||||||
|
opacity: 1,
|
||||||
|
showFire: false,
|
||||||
|
rotationSpeed: 0,
|
||||||
|
glowColor: 'transparent',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the actual rendered viewport within an SVG element.
|
* Calculate the actual rendered viewport within an SVG element.
|
||||||
* SVG uses preserveAspectRatio="xMidYMid meet" by default, which:
|
* SVG uses preserveAspectRatio="xMidYMid meet" by default, which:
|
||||||
|
|
@ -1133,6 +1307,53 @@ export function MapRenderer({
|
||||||
config: { tension: 120, friction: 20 },
|
config: { tension: 120, friction: 20 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get crosshair heat styling from the REAL hot/cold feedback system
|
||||||
|
const crosshairHeatStyle = getHeatCrosshairStyle(
|
||||||
|
hotColdFeedbackType,
|
||||||
|
isDark,
|
||||||
|
effectiveHotColdEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debounced rotation state to prevent flicker from feedback type flickering
|
||||||
|
// Start rotating immediately when speed > 0, but delay stopping by 150ms
|
||||||
|
const rawShouldRotate = crosshairHeatStyle.rotationSpeed > 0
|
||||||
|
const [debouncedShouldRotate, setDebouncedShouldRotate] = useState(false)
|
||||||
|
const stopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawShouldRotate) {
|
||||||
|
// Start immediately
|
||||||
|
if (stopTimeoutRef.current) {
|
||||||
|
clearTimeout(stopTimeoutRef.current)
|
||||||
|
stopTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
setDebouncedShouldRotate(true)
|
||||||
|
} else {
|
||||||
|
// Delay stopping to prevent flicker
|
||||||
|
if (!stopTimeoutRef.current) {
|
||||||
|
stopTimeoutRef.current = setTimeout(() => {
|
||||||
|
setDebouncedShouldRotate(false)
|
||||||
|
stopTimeoutRef.current = null
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [rawShouldRotate])
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (stopTimeoutRef.current) {
|
||||||
|
clearTimeout(stopTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Calculate CSS animation duration from rotation speed
|
||||||
|
// rotationSpeed is degrees per frame at 60fps
|
||||||
|
// duration = 360 degrees / (speed * 60 frames) seconds
|
||||||
|
const rotationDuration =
|
||||||
|
crosshairHeatStyle.rotationSpeed > 0 ? 360 / (crosshairHeatStyle.rotationSpeed * 60) : 1 // fallback, won't be used when paused
|
||||||
|
|
||||||
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
|
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
|
||||||
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
|
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3197,7 +3418,8 @@ export function MapRenderer({
|
||||||
// Fill the entire container - viewBox controls what portion of map is visible
|
// Fill the entire container - viewBox controls what portion of map is visible
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
cursor: pointerLocked ? 'crosshair' : 'pointer',
|
// Hide native cursor on desktop since we show custom crosshair
|
||||||
|
cursor: hasAnyFinePointer ? 'none' : 'pointer',
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -3330,7 +3552,8 @@ export function MapRenderer({
|
||||||
}
|
}
|
||||||
}} // Disable clicks on excluded regions and during celebration
|
}} // Disable clicks on excluded regions and during celebration
|
||||||
style={{
|
style={{
|
||||||
cursor: isExcluded ? 'default' : 'pointer',
|
// Hide native cursor on desktop (custom crosshair shown instead)
|
||||||
|
cursor: hasAnyFinePointer ? 'none' : isExcluded ? 'default' : 'pointer',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
// Ensure entire path interior is clickable, not just visible fill
|
// Ensure entire path interior is clickable, not just visible fill
|
||||||
pointerEvents: isExcluded ? 'none' : 'all',
|
pointerEvents: isExcluded ? 'none' : 'all',
|
||||||
|
|
@ -3679,7 +3902,8 @@ export function MapRenderer({
|
||||||
top: `${label.labelY}px`,
|
top: `${label.labelY}px`,
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
cursor: 'pointer',
|
// Hide native cursor on desktop (custom crosshair shown instead)
|
||||||
|
cursor: hasAnyFinePointer ? 'none' : 'pointer',
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
@ -3782,8 +4006,8 @@ export function MapRenderer({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom Cursor - Visible when pointer lock is active */}
|
{/* Custom Cursor - Visible on desktop when cursor is on the map */}
|
||||||
{pointerLocked && cursorPosition && (
|
{cursorPosition && hasAnyFinePointer && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
data-element="custom-cursor"
|
data-element="custom-cursor"
|
||||||
|
|
@ -3791,51 +4015,146 @@ export function MapRenderer({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${cursorPosition.x}px`,
|
left: `${cursorPosition.x}px`,
|
||||||
top: `${cursorPosition.y}px`,
|
top: `${cursorPosition.y}px`,
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 200,
|
zIndex: 200,
|
||||||
transform: `translate(-50%, -50%) scale(${cursorSquish.x}, ${cursorSquish.y})`,
|
transform: `translate(-50%, -50%) scale(${cursorSquish.x}, ${cursorSquish.y})`,
|
||||||
backgroundColor: 'transparent',
|
transition: 'transform 0.1s ease-out',
|
||||||
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
|
|
||||||
transition: 'transform 0.1s ease-out', // Smooth squish animation
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Crosshair - Vertical line */}
|
{/* Glow effect behind crosshair when hot - uses instantHeat for instant feedback */}
|
||||||
|
{crosshairHeatStyle.glowColor !== 'transparent' && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
top: '0',
|
top: '50%',
|
||||||
width: '2px',
|
transform: 'translate(-50%, -50%)',
|
||||||
height: '100%',
|
width: '40px',
|
||||||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
height: '40px',
|
||||||
transform: 'translateX(-50%)',
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${crosshairHeatStyle.glowColor} 0%, transparent 70%)`,
|
||||||
|
filter: 'blur(4px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Crosshair - Horizontal line */}
|
)}
|
||||||
|
{/* Enhanced SVG crosshair with heat effects - uses CSS animation for smooth rotation */}
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5)',
|
||||||
|
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||||
|
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<circle
|
||||||
|
cx="16"
|
||||||
|
cy="16"
|
||||||
|
r="10"
|
||||||
|
fill="none"
|
||||||
|
stroke={crosshairHeatStyle.color}
|
||||||
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - top */}
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="2"
|
||||||
|
x2="16"
|
||||||
|
y2="10"
|
||||||
|
stroke={crosshairHeatStyle.color}
|
||||||
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - bottom */}
|
||||||
|
<line
|
||||||
|
x1="16"
|
||||||
|
y1="22"
|
||||||
|
x2="16"
|
||||||
|
y2="30"
|
||||||
|
stroke={crosshairHeatStyle.color}
|
||||||
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - left */}
|
||||||
|
<line
|
||||||
|
x1="2"
|
||||||
|
y1="16"
|
||||||
|
x2="10"
|
||||||
|
y2="16"
|
||||||
|
stroke={crosshairHeatStyle.color}
|
||||||
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - right */}
|
||||||
|
<line
|
||||||
|
x1="22"
|
||||||
|
y1="16"
|
||||||
|
x2="30"
|
||||||
|
y2="16"
|
||||||
|
stroke={crosshairHeatStyle.color}
|
||||||
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Center dot */}
|
||||||
|
<circle
|
||||||
|
cx="16"
|
||||||
|
cy="16"
|
||||||
|
r="2"
|
||||||
|
fill={crosshairHeatStyle.color}
|
||||||
|
opacity={crosshairHeatStyle.opacity}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Fire particles around crosshair */}
|
||||||
|
{crosshairHeatStyle.showFire && (
|
||||||
|
<div style={{ position: 'absolute', left: 0, top: 0, width: '32px', height: '32px' }}>
|
||||||
|
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => {
|
||||||
|
const rad = (angle * Math.PI) / 180
|
||||||
|
const dist = 20
|
||||||
|
const px = 16 + Math.cos(rad) * dist - 4
|
||||||
|
const py = 16 + Math.sin(rad) * dist - 4
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={`fire-cursor-${i}`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '0',
|
left: `${px}px`,
|
||||||
top: '50%',
|
top: `${py}px`,
|
||||||
width: '100%',
|
width: '8px',
|
||||||
height: '2px',
|
height: '8px',
|
||||||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
borderRadius: '50%',
|
||||||
transform: 'translateY(-50%)',
|
background: i % 2 === 0 ? '#ef4444' : '#f97316',
|
||||||
|
opacity: 0.9,
|
||||||
|
animation: `fireParticle${i % 3} 0.4s ease-out infinite`,
|
||||||
|
animationDelay: `${i * 0.05}s`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Cursor region name label - shows what to find under the cursor */}
|
{/* Cursor region name label - shows what to find under the cursor */}
|
||||||
{currentRegionName && (
|
{currentRegionName &&
|
||||||
|
(() => {
|
||||||
|
const labelHeatStyle = getHeatCrosshairStyle(
|
||||||
|
hotColdFeedbackType,
|
||||||
|
isDark,
|
||||||
|
effectiveHotColdEnabled
|
||||||
|
)
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
data-element="cursor-region-label"
|
data-element="cursor-region-label"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: `${cursorPosition.x}px`,
|
left: `${cursorPosition.x}px`,
|
||||||
top: `${cursorPosition.y + 18}px`,
|
top: `${cursorPosition.y + 22}px`,
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 201,
|
zIndex: 201,
|
||||||
|
|
@ -3843,25 +4162,19 @@ export function MapRenderer({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
gap: '4px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.95)' : 'rgba(219, 234, 254, 0.95)',
|
backgroundColor: isDark
|
||||||
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
|
? 'rgba(30, 58, 138, 0.95)'
|
||||||
|
: 'rgba(219, 234, 254, 0.95)',
|
||||||
|
border: `2px solid ${labelHeatStyle.color}`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
boxShadow:
|
||||||
|
labelHeatStyle.glowColor !== 'transparent'
|
||||||
|
? `0 2px 8px rgba(0, 0, 0, 0.3), 0 0 12px ${labelHeatStyle.glowColor}`
|
||||||
|
: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: Math.max(0.5, labelHeatStyle.opacity), // Keep label visible but dimmed
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Hot/cold feedback emoji - shows temperature when enabled */}
|
|
||||||
{effectiveHotColdEnabled && hotColdFeedbackType && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
marginRight: '2px',
|
|
||||||
}}
|
|
||||||
title={`Hot/cold: ${hotColdFeedbackType}`}
|
|
||||||
>
|
|
||||||
{getHotColdEmoji(hotColdFeedbackType)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
|
|
@ -3884,10 +4197,152 @@ export function MapRenderer({
|
||||||
</span>
|
</span>
|
||||||
{currentFlagEmoji && <span style={{ fontSize: '14px' }}>{currentFlagEmoji}</span>}
|
{currentFlagEmoji && <span style={{ fontSize: '14px' }}>{currentFlagEmoji}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Heat crosshair overlay on main map - shows when hot/cold enabled (desktop non-pointer-lock) */}
|
||||||
|
{effectiveHotColdEnabled &&
|
||||||
|
cursorPosition &&
|
||||||
|
!pointerLocked &&
|
||||||
|
hasAnyFinePointer &&
|
||||||
|
(() => {
|
||||||
|
const heatStyle = getHeatCrosshairStyle(
|
||||||
|
hotColdFeedbackType,
|
||||||
|
isDark,
|
||||||
|
effectiveHotColdEnabled
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-element="main-map-heat-crosshair"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${cursorPosition.x}px`,
|
||||||
|
top: `${cursorPosition.y}px`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 150,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Glow effect behind crosshair when hot */}
|
||||||
|
{heatStyle.glowColor !== 'transparent' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: '50px',
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${heatStyle.glowColor} 0%, transparent 70%)`,
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Enhanced SVG crosshair with heat effects - uses CSS animation */}
|
||||||
|
<svg
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
style={{
|
||||||
|
filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.6))',
|
||||||
|
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||||
|
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<circle
|
||||||
|
cx="20"
|
||||||
|
cy="20"
|
||||||
|
r="12"
|
||||||
|
fill="none"
|
||||||
|
stroke={heatStyle.color}
|
||||||
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - top */}
|
||||||
|
<line
|
||||||
|
x1="20"
|
||||||
|
y1="3"
|
||||||
|
x2="20"
|
||||||
|
y2="12"
|
||||||
|
stroke={heatStyle.color}
|
||||||
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - bottom */}
|
||||||
|
<line
|
||||||
|
x1="20"
|
||||||
|
y1="28"
|
||||||
|
x2="20"
|
||||||
|
y2="37"
|
||||||
|
stroke={heatStyle.color}
|
||||||
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - left */}
|
||||||
|
<line
|
||||||
|
x1="3"
|
||||||
|
y1="20"
|
||||||
|
x2="12"
|
||||||
|
y2="20"
|
||||||
|
stroke={heatStyle.color}
|
||||||
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Cross lines - right */}
|
||||||
|
<line
|
||||||
|
x1="28"
|
||||||
|
y1="20"
|
||||||
|
x2="37"
|
||||||
|
y2="20"
|
||||||
|
stroke={heatStyle.color}
|
||||||
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
{/* Center dot */}
|
||||||
|
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
||||||
|
</svg>
|
||||||
|
{/* Fire particles around crosshair */}
|
||||||
|
{heatStyle.showFire && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'absolute', left: 0, top: 0, width: '40px', height: '40px' }}
|
||||||
|
>
|
||||||
|
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => {
|
||||||
|
const rad = (angle * Math.PI) / 180
|
||||||
|
const distance = 24
|
||||||
|
const px = 20 + Math.cos(rad) * distance - 5
|
||||||
|
const py = 20 + Math.sin(rad) * distance - 5
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`fire-main-${i}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${px}px`,
|
||||||
|
top: `${py}px`,
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: i % 2 === 0 ? '#ef4444' : '#f97316',
|
||||||
|
opacity: 0.9,
|
||||||
|
animation: `fireParticle${i % 3} 0.4s ease-out infinite`,
|
||||||
|
animationDelay: `${i * 0.05}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Magnifier overlay - centers on cursor position */}
|
{/* Magnifier overlay - centers on cursor position */}
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!cursorPosition || !svgRef.current || !containerRef.current) {
|
if (!cursorPosition || !svgRef.current || !containerRef.current) {
|
||||||
|
|
@ -4136,7 +4591,7 @@ export function MapRenderer({
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Crosshair at center position (cursor or reveal center during animation) */}
|
{/* Crosshair at center position (cursor or reveal center during animation) */}
|
||||||
<g>
|
<g data-element="magnifier-crosshair">
|
||||||
{(() => {
|
{(() => {
|
||||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||||
const svgRect = svgRef.current!.getBoundingClientRect()
|
const svgRect = svgRef.current!.getBoundingClientRect()
|
||||||
|
|
@ -4158,35 +4613,102 @@ export function MapRenderer({
|
||||||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||||||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
||||||
|
|
||||||
|
// Get heat-based crosshair styling
|
||||||
|
const heatStyle = getHeatCrosshairStyle(
|
||||||
|
hotColdFeedbackType,
|
||||||
|
isDark,
|
||||||
|
effectiveHotColdEnabled
|
||||||
|
)
|
||||||
|
const crosshairRadius = viewBoxWidth / 100
|
||||||
|
const crosshairLineLength = viewBoxWidth / 50
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Glow effect behind crosshair when hot */}
|
||||||
|
{heatStyle.glowColor !== 'transparent' && (
|
||||||
<circle
|
<circle
|
||||||
cx={cursorSvgX}
|
cx={cursorSvgX}
|
||||||
cy={cursorSvgY}
|
cy={cursorSvgY}
|
||||||
r={viewBoxWidth / 100}
|
r={crosshairRadius * 1.5}
|
||||||
|
fill={heatStyle.glowColor}
|
||||||
|
opacity={0.5}
|
||||||
|
style={{
|
||||||
|
filter: 'blur(3px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Crosshair with separate translation and rotation */}
|
||||||
|
{/* Outer <g> handles translation (follows cursor) */}
|
||||||
|
{/* Inner <g> handles rotation via CSS animation */}
|
||||||
|
<g transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
|
||||||
|
<g
|
||||||
|
style={{
|
||||||
|
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
|
||||||
|
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Main crosshair circle - drawn at origin */}
|
||||||
|
<circle
|
||||||
|
cx={0}
|
||||||
|
cy={0}
|
||||||
|
r={crosshairRadius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
stroke={heatStyle.color}
|
||||||
strokeWidth={viewBoxWidth / 500}
|
strokeWidth={(viewBoxWidth / 500) * (heatStyle.strokeWidth / 2)}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
{/* Horizontal crosshair line - drawn at origin */}
|
||||||
<line
|
<line
|
||||||
x1={cursorSvgX - viewBoxWidth / 50}
|
x1={-crosshairLineLength}
|
||||||
y1={cursorSvgY}
|
y1={0}
|
||||||
x2={cursorSvgX + viewBoxWidth / 50}
|
x2={crosshairLineLength}
|
||||||
y2={cursorSvgY}
|
y2={0}
|
||||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
stroke={heatStyle.color}
|
||||||
strokeWidth={viewBoxWidth / 1000}
|
strokeWidth={(viewBoxWidth / 1000) * (heatStyle.strokeWidth / 2)}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
{/* Vertical crosshair line - drawn at origin */}
|
||||||
<line
|
<line
|
||||||
x1={cursorSvgX}
|
x1={0}
|
||||||
y1={cursorSvgY - viewBoxHeight / 50}
|
y1={-crosshairLineLength}
|
||||||
x2={cursorSvgX}
|
x2={0}
|
||||||
y2={cursorSvgY + viewBoxHeight / 50}
|
y2={crosshairLineLength}
|
||||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
stroke={heatStyle.color}
|
||||||
strokeWidth={viewBoxWidth / 1000}
|
strokeWidth={(viewBoxWidth / 1000) * (heatStyle.strokeWidth / 2)}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/* Fire particles around crosshair when on_fire or found_it */}
|
||||||
|
{heatStyle.showFire && (
|
||||||
|
<>
|
||||||
|
{/* Fire particles - 6 small circles radiating outward */}
|
||||||
|
{[0, 60, 120, 180, 240, 300].map((angle, i) => {
|
||||||
|
const rad = (angle * Math.PI) / 180
|
||||||
|
const particleDistance = crosshairRadius * 1.8
|
||||||
|
const px = cursorSvgX + Math.cos(rad) * particleDistance
|
||||||
|
const py = cursorSvgY + Math.sin(rad) * particleDistance
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`fire-${i}`}
|
||||||
|
cx={px}
|
||||||
|
cy={py}
|
||||||
|
r={crosshairRadius * 0.25}
|
||||||
|
fill={i % 2 === 0 ? '#ef4444' : '#f97316'}
|
||||||
|
opacity={0.8}
|
||||||
|
style={{
|
||||||
|
animation: `fireParticle${i % 3} 0.5s ease-out infinite`,
|
||||||
|
animationDelay: `${i * 0.08}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -5048,6 +5570,25 @@ export function MapRenderer({
|
||||||
from { stroke-dashoffset: 12; }
|
from { stroke-dashoffset: 12; }
|
||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
|
@keyframes crosshairSpin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes fireParticle0 {
|
||||||
|
0% { opacity: 0.9; transform: scale(1) translateY(0); }
|
||||||
|
50% { opacity: 1; transform: scale(1.3) translateY(-2px); }
|
||||||
|
100% { opacity: 0.9; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fireParticle1 {
|
||||||
|
0% { opacity: 0.8; transform: scale(1.1) translateY(-1px); }
|
||||||
|
50% { opacity: 1; transform: scale(1) translateY(1px); }
|
||||||
|
100% { opacity: 0.8; transform: scale(1.1) translateY(-1px); }
|
||||||
|
}
|
||||||
|
@keyframes fireParticle2 {
|
||||||
|
0% { opacity: 1; transform: scale(1.2) translateY(-2px); }
|
||||||
|
50% { opacity: 0.7; transform: scale(0.9) translateY(0); }
|
||||||
|
100% { opacity: 1; transform: scale(1.2) translateY(-2px); }
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue