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 type { MapData, MapRegion } from '../types'
|
||||
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
|
||||
import { CELEBRATION_TIMING, classifyCelebration } from '../utils/celebration'
|
||||
import type { FeedbackType } from '../utils/hotColdPhrases'
|
||||
import {
|
||||
getAdjustedMagnifiedDimensions,
|
||||
|
|
@ -49,7 +50,6 @@ import {
|
|||
calculateScreenPixelRatio,
|
||||
isAboveThreshold,
|
||||
} from '../utils/screenPixelRatio'
|
||||
import { classifyCelebration, CELEBRATION_TIMING } from '../utils/celebration'
|
||||
import { CelebrationOverlay } from './CelebrationOverlay'
|
||||
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.
|
||||
* SVG uses preserveAspectRatio="xMidYMid meet" by default, which:
|
||||
|
|
@ -1133,6 +1307,53 @@ export function MapRenderer({
|
|||
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
|
||||
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
|
||||
useEffect(() => {
|
||||
|
|
@ -3197,7 +3418,8 @@ export function MapRenderer({
|
|||
// Fill the entire container - viewBox controls what portion of map is visible
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: pointerLocked ? 'crosshair' : 'pointer',
|
||||
// Hide native cursor on desktop since we show custom crosshair
|
||||
cursor: hasAnyFinePointer ? 'none' : 'pointer',
|
||||
transformOrigin: 'center center',
|
||||
})}
|
||||
style={{
|
||||
|
|
@ -3330,7 +3552,8 @@ export function MapRenderer({
|
|||
}
|
||||
}} // Disable clicks on excluded regions and during celebration
|
||||
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',
|
||||
// Ensure entire path interior is clickable, not just visible fill
|
||||
pointerEvents: isExcluded ? 'none' : 'all',
|
||||
|
|
@ -3679,7 +3902,8 @@ export function MapRenderer({
|
|||
top: `${label.labelY}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'all',
|
||||
cursor: 'pointer',
|
||||
// Hide native cursor on desktop (custom crosshair shown instead)
|
||||
cursor: hasAnyFinePointer ? 'none' : 'pointer',
|
||||
zIndex: 20,
|
||||
}}
|
||||
onClick={() =>
|
||||
|
|
@ -3782,8 +4006,8 @@ export function MapRenderer({
|
|||
)
|
||||
})}
|
||||
|
||||
{/* Custom Cursor - Visible when pointer lock is active */}
|
||||
{pointerLocked && cursorPosition && (
|
||||
{/* Custom Cursor - Visible on desktop when cursor is on the map */}
|
||||
{cursorPosition && hasAnyFinePointer && (
|
||||
<>
|
||||
<div
|
||||
data-element="custom-cursor"
|
||||
|
|
@ -3791,51 +4015,146 @@ export function MapRenderer({
|
|||
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
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Crosshair - Vertical line */}
|
||||
{/* Glow effect behind crosshair when hot - uses instantHeat for instant feedback */}
|
||||
{crosshairHeatStyle.glowColor !== 'transparent' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '0',
|
||||
width: '2px',
|
||||
height: '100%',
|
||||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
||||
transform: 'translateX(-50%)',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
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
|
||||
key={`fire-cursor-${i}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
height: '2px',
|
||||
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
|
||||
transform: 'translateY(-50%)',
|
||||
left: `${px}px`,
|
||||
top: `${py}px`,
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
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>
|
||||
{/* Cursor region name label - shows what to find under the cursor */}
|
||||
{currentRegionName && (
|
||||
{currentRegionName &&
|
||||
(() => {
|
||||
const labelHeatStyle = getHeatCrosshairStyle(
|
||||
hotColdFeedbackType,
|
||||
isDark,
|
||||
effectiveHotColdEnabled
|
||||
)
|
||||
return (
|
||||
<div
|
||||
data-element="cursor-region-label"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${cursorPosition.x}px`,
|
||||
top: `${cursorPosition.y + 18}px`,
|
||||
top: `${cursorPosition.y + 22}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 201,
|
||||
|
|
@ -3843,25 +4162,19 @@ export function MapRenderer({
|
|||
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'}`,
|
||||
backgroundColor: isDark
|
||||
? 'rgba(30, 58, 138, 0.95)'
|
||||
: 'rgba(219, 234, 254, 0.95)',
|
||||
border: `2px solid ${labelHeatStyle.color}`,
|
||||
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',
|
||||
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
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
|
|
@ -3884,10 +4197,152 @@ export function MapRenderer({
|
|||
</span>
|
||||
{currentFlagEmoji && <span style={{ fontSize: '14px' }}>{currentFlagEmoji}</span>}
|
||||
</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 */}
|
||||
{(() => {
|
||||
if (!cursorPosition || !svgRef.current || !containerRef.current) {
|
||||
|
|
@ -4136,7 +4591,7 @@ export function MapRenderer({
|
|||
})}
|
||||
|
||||
{/* Crosshair at center position (cursor or reveal center during animation) */}
|
||||
<g>
|
||||
<g data-element="magnifier-crosshair">
|
||||
{(() => {
|
||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||
const svgRect = svgRef.current!.getBoundingClientRect()
|
||||
|
|
@ -4158,35 +4613,102 @@ export function MapRenderer({
|
|||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||||
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 (
|
||||
<>
|
||||
{/* Glow effect behind crosshair when hot */}
|
||||
{heatStyle.glowColor !== 'transparent' && (
|
||||
<circle
|
||||
cx={cursorSvgX}
|
||||
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"
|
||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
||||
strokeWidth={viewBoxWidth / 500}
|
||||
stroke={heatStyle.color}
|
||||
strokeWidth={(viewBoxWidth / 500) * (heatStyle.strokeWidth / 2)}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
opacity={heatStyle.opacity}
|
||||
/>
|
||||
{/* Horizontal crosshair line - drawn at origin */}
|
||||
<line
|
||||
x1={cursorSvgX - viewBoxWidth / 50}
|
||||
y1={cursorSvgY}
|
||||
x2={cursorSvgX + viewBoxWidth / 50}
|
||||
y2={cursorSvgY}
|
||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
||||
strokeWidth={viewBoxWidth / 1000}
|
||||
x1={-crosshairLineLength}
|
||||
y1={0}
|
||||
x2={crosshairLineLength}
|
||||
y2={0}
|
||||
stroke={heatStyle.color}
|
||||
strokeWidth={(viewBoxWidth / 1000) * (heatStyle.strokeWidth / 2)}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
opacity={heatStyle.opacity}
|
||||
/>
|
||||
{/* Vertical crosshair line - drawn at origin */}
|
||||
<line
|
||||
x1={cursorSvgX}
|
||||
y1={cursorSvgY - viewBoxHeight / 50}
|
||||
x2={cursorSvgX}
|
||||
y2={cursorSvgY + viewBoxHeight / 50}
|
||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
||||
strokeWidth={viewBoxWidth / 1000}
|
||||
x1={0}
|
||||
y1={-crosshairLineLength}
|
||||
x2={0}
|
||||
y2={crosshairLineLength}
|
||||
stroke={heatStyle.color}
|
||||
strokeWidth={(viewBoxWidth / 1000) * (heatStyle.strokeWidth / 2)}
|
||||
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; }
|
||||
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>
|
||||
</svg>
|
||||
|
|
|
|||
Loading…
Reference in New Issue