fix(know-your-world): use spring-for-speed pattern for smooth crosshair rotation

Replace CSS animation with spring-for-speed, manual-integration-for-angle pattern:
- Spring animates rotation SPEED for smooth transitions between heat levels
- requestAnimationFrame loop integrates angle from speed (no jumps)
- useSpringValue binds angle directly to animated SVG elements

This solves position jumps that occurred when CSS animation duration changed.

Add documentation:
- .claude/ANIMATION_PATTERNS.md - complete pattern explanation
- Reference in CLAUDE.md for future similar tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-01 15:27:03 -06:00
parent af5e7b59dc
commit b7fe2af369
3 changed files with 203 additions and 50 deletions

View File

@ -0,0 +1,111 @@
# Animation Patterns
## Spring-for-Speed, Manual-Integration-for-Angle Pattern
When animating continuous rotation where the **speed changes smoothly** but you need to **avoid position jumps**, use this pattern.
### The Problem
**CSS Animation approach fails because:**
- Changing `animation-duration` resets the animation phase, causing jumps
- `animation-delay` tricks don't reliably preserve position across speed changes
**Calling `spring.start()` 60fps fails because:**
- React-spring's internal batching can't keep up with 60fps updates
- Spring value lags 1000+ degrees behind, causing wild spinning
- React re-renders interfere with spring updates
### The Solution: Decouple Speed and Angle
```typescript
import { animated, useSpringValue } from '@react-spring/web'
// 1. Spring for SPEED (this is what transitions smoothly)
const rotationSpeed = useSpringValue(0, {
config: { tension: 200, friction: 30 },
})
// 2. Spring value for ANGLE (we'll .set() this directly, no springing)
const rotationAngle = useSpringValue(0)
// 3. Update speed spring when target changes
useEffect(() => {
rotationSpeed.start(targetSpeedDegPerSec)
}, [targetSpeedDegPerSec, rotationSpeed])
// 4. requestAnimationFrame loop integrates angle from speed
useEffect(() => {
let lastTime = performance.now()
let frameId: number
const loop = (now: number) => {
const dt = (now - lastTime) / 1000 // seconds
lastTime = now
const speed = rotationSpeed.get() // deg/s from the spring
let angle = rotationAngle.get() + speed * dt // integrate
// Keep angle in reasonable range (prevent overflow)
if (angle >= 360000) angle -= 360000
if (angle < 0) angle += 360
// Direct set - no extra springing on angle itself
rotationAngle.set(angle)
frameId = requestAnimationFrame(loop)
}
frameId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(frameId)
}, [rotationSpeed, rotationAngle])
// 5. Bind angle to animated element
<animated.svg
style={{
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* SVG content */}
</animated.svg>
```
### Why This Works
1. **Speed spring handles smooth transitions**: When target speed changes, the spring smoothly interpolates. No jumps.
2. **Manual integration preserves continuity**: `angle += speed * dt` always adds to the current angle. The angle never resets or jumps.
3. **Direct `.set()` avoids lag**: We're not asking the spring to animate the angle - we're directly setting it 60 times per second. No batching issues.
4. **`useSpringValue` enables binding**: Unlike a plain ref, `useSpringValue` can be bound to animated elements via `.to()`.
### Key Insights
- **Spring the derivative, integrate the value**: Speed is the derivative of angle. Spring the speed, integrate to get angle.
- **Never spring something you're updating 60fps**: The spring can't keep up. Use `.set()` instead of `.start()`.
- **Keep integration in rAF, not React effects**: React effects can skip frames or batch. rAF is reliable.
### When to Use This Pattern
- Rotating elements where rotation speed changes based on state
- Scrolling effects where scroll speed should transition smoothly
- Any continuous animation where the RATE of change should animate, not the value itself
### Anti-Patterns to Avoid
```typescript
// BAD: Calling start() in rAF loop
const loop = () => {
angle.start(currentAngle + speed * dt) // Will lag behind!
}
// BAD: CSS animation with dynamic duration
style={{
animation: `spin ${1/speed}s linear infinite` // Jumps on speed change!
}}
// BAD: Changing animation-delay to preserve position
style={{
animationDelay: `-${currentAngle / 360 * duration}s` // Unreliable!
}}
```

View File

@ -629,6 +629,40 @@ import { AbacusReact } from '@soroban/abacus-react'
**Status:** Known issue, does not block development or deployment.
## Animation Patterns (React-Spring)
When implementing continuous animations with smoothly-changing speeds (like rotating crosshairs), refer to:
- **`.claude/ANIMATION_PATTERNS.md`** - Spring-for-speed, manual-integration-for-angle pattern
- Why CSS animation and naive react-spring approaches fail
- How to decouple speed (spring-animated) from angle (manually integrated)
- Complete code example with `useSpringValue` and `requestAnimationFrame`
- Anti-patterns to avoid
**Quick Reference:**
```typescript
// Spring the SPEED, integrate the ANGLE
const rotationSpeed = useSpringValue(0, { config: { tension: 200, friction: 30 } })
const rotationAngle = useSpringValue(0)
// Update speed spring when target changes
useEffect(() => { rotationSpeed.start(targetSpeed) }, [targetSpeed])
// rAF loop integrates angle from speed
useEffect(() => {
const loop = (now) => {
const dt = (now - lastTime) / 1000
rotationAngle.set(rotationAngle.get() + rotationSpeed.get() * dt)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}, [])
// Bind to animated element
<animated.svg style={{ transform: rotationAngle.to(a => `rotate(${a}deg)`) }} />
```
## Game Settings Persistence
When working on arcade room game settings, refer to:

View File

@ -1,6 +1,6 @@
'use client'
import { animated, to, useSpring } from '@react-spring/web'
import { animated, to, useSpring, useSpringValue } 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'
@ -1314,45 +1314,56 @@ export function MapRenderer({
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)
// === Crosshair rotation using spring-for-speed, manual-integration-for-angle pattern ===
// This gives smooth speed transitions without the issues of CSS animation or
// calling spring.start() 60 times per second.
//
// Pattern:
// 1. Spring animates the SPEED (degrees per second) - smooth transitions
// 2. requestAnimationFrame loop integrates angle from speed
// 3. Angle is bound to animated element via useSpringValue
// Convert rotation speed from degrees/frame@60fps to degrees/second
const targetSpeedDegPerSec = crosshairHeatStyle.rotationSpeed * 60
// Spring for rotation speed - this is what makes speed changes smooth
const rotationSpeed = useSpringValue(0, {
config: { tension: 200, friction: 30 },
})
// Spring value for the angle - we'll directly .set() this from the rAF loop
const rotationAngle = useSpringValue(0)
// Update the speed spring when target changes
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])
rotationSpeed.start(targetSpeedDegPerSec)
}, [targetSpeedDegPerSec, rotationSpeed])
// Cleanup timeout on unmount
// requestAnimationFrame loop to integrate angle from speed
useEffect(() => {
return () => {
if (stopTimeoutRef.current) {
clearTimeout(stopTimeoutRef.current)
}
}
}, [])
let lastTime = performance.now()
let frameId: number
// 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
const loop = (now: number) => {
const dt = (now - lastTime) / 1000 // seconds
lastTime = now
const speed = rotationSpeed.get() // deg/s from the spring
let angle = rotationAngle.get() + speed * dt // integrate
// Keep angle in reasonable range (prevent overflow after hours of play)
if (angle >= 360000) angle -= 360000
if (angle < 0) angle += 360
// Direct set - no extra springing on angle itself
rotationAngle.set(angle)
frameId = requestAnimationFrame(loop)
}
frameId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(frameId)
}, [rotationSpeed, rotationAngle])
// Note: Zoom animation with pause/resume is now handled by useMagnifierZoom hook
// This effect only updates the remaining spring properties: opacity, position, movement multiplier
@ -4037,15 +4048,14 @@ export function MapRenderer({
}}
/>
)}
{/* Enhanced SVG crosshair with heat effects - uses CSS animation for smooth rotation */}
<svg
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
<animated.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',
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* Outer ring */}
@ -4110,7 +4120,7 @@ export function MapRenderer({
fill={crosshairHeatStyle.color}
opacity={crosshairHeatStyle.opacity}
/>
</svg>
</animated.svg>
{/* Fire particles around crosshair */}
{crosshairHeatStyle.showFire && (
<div style={{ position: 'absolute', left: 0, top: 0, width: '32px', height: '32px' }}>
@ -4241,15 +4251,14 @@ export function MapRenderer({
}}
/>
)}
{/* Enhanced SVG crosshair with heat effects - uses CSS animation */}
<svg
{/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */}
<animated.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',
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
}}
>
{/* Outer ring */}
@ -4308,7 +4317,7 @@ export function MapRenderer({
/>
{/* Center dot */}
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
</svg>
</animated.svg>
{/* Fire particles around crosshair */}
{heatStyle.showFire && (
<div
@ -4639,12 +4648,11 @@ export function MapRenderer({
)}
{/* Crosshair with separate translation and rotation */}
{/* Outer <g> handles translation (follows cursor) */}
{/* Inner <g> handles rotation via CSS animation */}
{/* Inner animated.g handles rotation via spring-driven animation */}
<g transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
<g
<animated.g
style={{
animation: `crosshairSpin ${rotationDuration}s linear infinite`,
animationPlayState: debouncedShouldRotate ? 'running' : 'paused',
transform: rotationAngle.to((a) => `rotate(${a}deg)`),
transformOrigin: '0 0',
}}
>
@ -4681,7 +4689,7 @@ export function MapRenderer({
vectorEffect="non-scaling-stroke"
opacity={heatStyle.opacity}
/>
</g>
</animated.g>
</g>
{/* Fire particles around crosshair when on_fire or found_it */}
{heatStyle.showFire && (