feat(card-sorting): add react-spring animations for real-time sync

Add smooth physics-based animations for card and arrow movements:
- Add @react-spring/web dependency
- Create AnimatedCard component with spring animations
- Create AnimatedArrow component with spring animations
- Add viewport resize handling with debounced isResizing flag
- Conditionally disable animations during drag and resize (immediate mode)
- Enable spring animations only for socket position updates
- Add throttled position syncing (100ms) during drag operations
- Convert all positioning to viewport percentages (0-100)
- Track viewport dimensions and reposition cards on resize

Cards and arrows now smoothly animate when positions update from other
connected windows, while maintaining instant feedback during local
drag operations and viewport resizes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-23 14:33:07 -05:00
parent f6ed4a27a2
commit c367e0ceec
3 changed files with 425 additions and 159 deletions

View File

@@ -46,7 +46,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.2",
"@react-spring/web": "^10.0.3",
"@soroban/abacus-react": "workspace:*",
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",

View File

@@ -3,6 +3,7 @@
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
import { useState, useEffect, useRef } from 'react'
import { useSpring, animated } from '@react-spring/web'
import type { SortingCard } from '../types'
// Add celebration animations
@@ -54,9 +55,9 @@ if (typeof document !== 'undefined') {
}
interface CardState {
x: number
y: number
rotation: number
x: number // % of viewport width (0-100)
y: number // % of viewport height (0-100)
rotation: number // degrees
zIndex: number
}
@@ -65,16 +66,18 @@ interface CardState {
* Uses a horizontal left-to-right ordering with some vertical tolerance.
*
* Algorithm:
* 1. Group cards into horizontal "lanes" (vertical tolerance of ~60px)
* 1. Group cards into horizontal "lanes" (vertical tolerance)
* 2. Within each lane, sort left-to-right by x position
* 3. Sort lanes top-to-bottom
* 4. Flatten to get final sequence
*
* Note: Positions are in viewport percentages (0-100)
*/
function inferSequenceFromPositions(
cardStates: Map<string, CardState>,
allCards: SortingCard[]
): SortingCard[] {
const VERTICAL_TOLERANCE = 60 // Cards within 60px vertically are in the same "lane"
const VERTICAL_TOLERANCE = 8 // Cards within 8% of viewport height are in the same "lane"
// Get all positioned cards
const positionedCards = allCards
@@ -129,6 +132,226 @@ function inferSequenceFromPositions(
return lanes.flat().map((item) => item.card)
}
/**
* Animated arrow component using react-spring for smooth movements
*/
function AnimatedArrow({
fromCard,
toCard,
isCorrect,
sequenceNumber,
isDragging,
isResizing,
viewportWidth,
viewportHeight,
}: {
fromCard: CardState
toCard: CardState
isCorrect: boolean
sequenceNumber: number
isDragging: boolean
isResizing: boolean
viewportWidth: number
viewportHeight: number
}) {
// Convert percentage positions to pixels
const fromPx = {
x: (fromCard.x / 100) * viewportWidth,
y: (fromCard.y / 100) * viewportHeight,
}
const toPx = {
x: (toCard.x / 100) * viewportWidth,
y: (toCard.y / 100) * viewportHeight,
}
// Calculate arrow position (from center of current card to center of next card)
const fromX = fromPx.x + 70 // 70 = half of card width (140px)
const fromY = fromPx.y + 90 // 90 = half of card height (180px)
const toX = toPx.x + 70
const toY = toPx.y + 90
// Calculate angle and distance
const dx = toX - fromX
const dy = toY - fromY
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
const distance = Math.sqrt(dx * dx + dy * dy)
// Don't draw arrow if cards are too close
if (distance < 80) return null
// Use spring animation for arrow position and size
// Disable animation when dragging or resizing
const springProps = useSpring({
fromX,
fromY,
distance,
angle,
immediate: isDragging || isResizing,
config: {
tension: 300,
friction: 30,
},
})
return (
<animated.div
style={{
position: 'absolute',
left: springProps.fromX.to((val) => `${val}px`),
top: springProps.fromY.to((val) => `${val}px`),
width: springProps.distance.to((val) => `${val}px`),
height: isCorrect ? '4px' : '3px',
transformOrigin: '0 50%',
transform: springProps.angle.to((val) => `rotate(${val}deg)`),
pointerEvents: 'none',
zIndex: 0,
animation: isCorrect ? 'correctArrowGlow 1.5s ease-in-out infinite' : 'none',
}}
>
{/* Arrow line */}
<div
style={{
width: '100%',
height: '100%',
background: isCorrect
? 'linear-gradient(90deg, rgba(34, 197, 94, 0.7) 0%, rgba(34, 197, 94, 0.9) 100%)'
: 'linear-gradient(90deg, rgba(251, 146, 60, 0.6) 0%, rgba(251, 146, 60, 0.8) 100%)',
position: 'relative',
}}
>
{/* Arrow head */}
<div
style={{
position: 'absolute',
right: '-8px',
top: '50%',
width: '0',
height: '0',
borderLeft: isCorrect
? '10px solid rgba(34, 197, 94, 0.9)'
: '10px solid rgba(251, 146, 60, 0.8)',
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
transform: 'translateY(-50%)',
}}
/>
{/* Sequence number badge */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
background: isCorrect ? 'rgba(34, 197, 94, 0.95)' : 'rgba(251, 146, 60, 0.95)',
color: 'white',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
border: '2px solid white',
boxShadow: isCorrect ? '0 0 12px rgba(34, 197, 94, 0.6)' : '0 2px 4px rgba(0,0,0,0.2)',
animation: isCorrect ? 'correctBadgePulse 1.5s ease-in-out infinite' : 'none',
}}
>
{sequenceNumber}
</div>
</div>
</animated.div>
)
}
/**
* Animated card component using react-spring for smooth movements
*/
function AnimatedCard({
card,
cardState,
isDragging,
isResizing,
isSpectating,
viewportWidth,
viewportHeight,
onPointerDown,
onPointerMove,
onPointerUp,
}: {
card: SortingCard
cardState: CardState
isDragging: boolean
isResizing: boolean
isSpectating: boolean
viewportWidth: number
viewportHeight: number
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: (e: React.PointerEvent) => void
}) {
// Convert percentage position to pixels for rendering
const pixelPos = {
x: (cardState.x / 100) * viewportWidth,
y: (cardState.y / 100) * viewportHeight,
}
// Use spring animation for position and rotation
// Disable animation when:
// - User is dragging (for immediate response)
// - Viewport is resizing (for instant repositioning)
const springProps = useSpring({
left: pixelPos.x,
top: pixelPos.y,
rotation: cardState.rotation,
immediate: isDragging || isResizing, // Instant updates when dragging or resizing
config: {
tension: 300,
friction: 30,
},
})
return (
<animated.div
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
className={css({
position: 'absolute',
width: '140px',
height: '180px',
cursor: isSpectating ? 'default' : 'grab',
touchAction: 'none',
userSelect: 'none',
transition: isDragging ? 'none' : 'box-shadow 0.2s ease',
})}
style={{
left: springProps.left.to((val) => `${val}px`),
top: springProps.top.to((val) => `${val}px`),
transform: springProps.rotation.to((val) => `rotate(${val}deg)`),
zIndex: cardState.zIndex,
boxShadow: isDragging ? '0 20px 40px rgba(0, 0, 0, 0.3)' : '0 4px 8px rgba(0, 0, 0, 0.15)',
}}
>
<div
className={css({
width: '100%',
height: '100%',
background: 'white',
borderRadius: '12px',
border: '3px solid #0369a1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
boxSizing: 'border-box',
})}
dangerouslySetInnerHTML={{ __html: card.svgContent }}
/>
</animated.div>
)
}
export function PlayingPhaseDrag() {
const {
state,
@@ -136,6 +359,7 @@ export function PlayingPhaseDrag() {
checkSolution,
revealNumbers,
goToSetup,
updateCardPositions,
canCheckSolution,
elapsedTime,
isSpectating,
@@ -155,11 +379,56 @@ export function PlayingPhaseDrag() {
const [draggingCardId, setDraggingCardId] = useState<string | null>(null)
const [nextZIndex, setNextZIndex] = useState(1)
// Track viewport dimensions for responsive positioning
const [viewportDimensions, setViewportDimensions] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 1000,
height: typeof window !== 'undefined' ? window.innerHeight : 800,
})
// Track if we're currently resizing to disable spring animations
const [isResizing, setIsResizing] = useState(false)
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Throttle position updates during drag (every 100ms)
const lastSyncTimeRef = useRef<number>(0)
// Track when we're waiting to check solution
const [waitingToCheck, setWaitingToCheck] = useState(false)
const cardsToInsertRef = useRef<SortingCard[]>([])
const currentInsertIndexRef = useRef(0)
// Handle viewport resize
useEffect(() => {
const handleResize = () => {
// Set resizing flag to disable spring animations
setIsResizing(true)
// Update viewport dimensions immediately
setViewportDimensions({
width: window.innerWidth,
height: window.innerHeight,
})
// Clear any existing timeout
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current)
}
// After 150ms of no resize events, re-enable spring animations
resizeTimeoutRef.current = setTimeout(() => {
setIsResizing(false)
}, 150)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current)
}
}
}, [])
// Initialize card positions when game starts or restarts
useEffect(() => {
// Reset when entering playing phase or when cards change
@@ -176,25 +445,97 @@ export function PlayingPhaseDrag() {
if (!shouldInitialize) return
// Use full viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const newStates = new Map<string, CardState>()
// Check if we have server positions to restore from
const hasServerPositions = state.cardPositions && state.cardPositions.length === allCards.length
allCards.forEach((card, index) => {
// Scatter cards randomly across the entire viewport
// Leave margin for card size (140x180) and UI elements
newStates.set(card.id, {
x: Math.random() * (viewportWidth - 180) + 20,
y: Math.random() * (viewportHeight - 250) + 80, // Extra margin for top UI
rotation: Math.random() * 30 - 15, // -15 to 15 degrees
zIndex: index,
})
const serverPos = state.cardPositions?.find((p) => p.cardId === card.id)
if (hasServerPositions && serverPos) {
// Restore from server (already in percentages)
newStates.set(card.id, {
x: serverPos.x,
y: serverPos.y,
rotation: serverPos.rotation,
zIndex: serverPos.zIndex,
})
} else {
// Generate random positions as percentages
// Leave margin for card size and UI elements
// Card is ~140px wide on ~1000px viewport = ~14% of width
// Card is ~180px tall on ~800px viewport = ~22.5% of height
const xMargin = 2 // 2% margin on sides
const yMargin = 10 // 10% margin for top UI
newStates.set(card.id, {
x: Math.random() * (100 - 2 * xMargin - 14) + xMargin,
y: Math.random() * (100 - yMargin - 22.5) + yMargin,
rotation: Math.random() * 30 - 15, // -15 to 15 degrees
zIndex: index,
})
}
})
setCardStates(newStates)
setNextZIndex(allCards.length)
}, [state.availableCards.length, state.placedCards.length, state.gameStartTime, cardStates.size])
setNextZIndex(Math.max(...Array.from(newStates.values()).map((s) => s.zIndex)) + 1)
// If we generated new positions (not restored from server), send them to server
if (!hasServerPositions && !isSpectating) {
const positions = Array.from(newStates.entries()).map(([id, cardState]) => ({
cardId: id,
x: cardState.x,
y: cardState.y,
rotation: cardState.rotation,
zIndex: cardState.zIndex,
}))
updateCardPositions(positions)
}
}, [
state.availableCards.length,
state.placedCards.length,
state.gameStartTime,
state.cardPositions?.length,
cardStates.size,
isSpectating,
updateCardPositions,
])
// Sync server position updates (for spectators and multi-window sync)
useEffect(() => {
if (!state.cardPositions || state.cardPositions.length === 0) return
if (cardStates.size === 0) return
// Check if server positions differ from current positions
let needsUpdate = false
const newStates = new Map(cardStates)
for (const serverPos of state.cardPositions) {
const currentState = cardStates.get(serverPos.cardId)
if (!currentState) continue
// Compare percentages directly (tolerance: 0.5%)
if (
Math.abs(currentState.x - serverPos.x) > 0.5 ||
Math.abs(currentState.y - serverPos.y) > 0.5 ||
Math.abs(currentState.rotation - serverPos.rotation) > 1 ||
currentState.zIndex !== serverPos.zIndex
) {
needsUpdate = true
newStates.set(serverPos.cardId, {
x: serverPos.x,
y: serverPos.y,
rotation: serverPos.rotation,
zIndex: serverPos.zIndex,
})
}
}
if (needsUpdate && !draggingCardId) {
// Only apply server updates if not currently dragging
setCardStates(newStates)
}
}, [state.cardPositions, draggingCardId, cardStates])
// Infer sequence from current positions
const inferredSequence = inferSequenceFromPositions(cardStates, [
@@ -248,9 +589,15 @@ export function PlayingPhaseDrag() {
const { offsetX, offsetY } = dragStateRef.current
// Calculate new position in viewport coordinates
const newX = e.clientX - offsetX
const newY = e.clientY - offsetY
// Calculate new position in pixels
const newXPx = e.clientX - offsetX
const newYPx = e.clientY - offsetY
// Convert to percentages
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const newX = (newXPx / viewportWidth) * 100
const newY = (newYPx / viewportHeight) * 100
// Calculate rotation based on drag velocity
const dragDeltaX = e.clientX - dragStateRef.current.startX
@@ -266,6 +613,22 @@ export function PlayingPhaseDrag() {
y: newY,
rotation,
})
// Send real-time position updates (throttled to every 100ms)
if (!isSpectating) {
const now = Date.now()
if (now - lastSyncTimeRef.current > 100) {
lastSyncTimeRef.current = now
const positions = Array.from(newStates.entries()).map(([id, state]) => ({
cardId: id,
x: state.x,
y: state.y,
rotation: state.rotation,
zIndex: state.zIndex,
}))
updateCardPositions(positions)
}
}
}
return newStates
})
@@ -279,17 +642,27 @@ export function PlayingPhaseDrag() {
target.releasePointerCapture(e.pointerId)
// Reset rotation to slight random tilt
setCardStates((prev) => {
const newStates = new Map(prev)
const cardState = newStates.get(cardId)
if (cardState) {
newStates.set(cardId, {
...cardState,
rotation: Math.random() * 10 - 5,
})
const updatedStates = new Map(cardStates)
const cardState = updatedStates.get(cardId)
if (cardState) {
updatedStates.set(cardId, {
...cardState,
rotation: Math.random() * 10 - 5,
})
setCardStates(updatedStates)
// Sync positions to server (already in percentages)
if (!isSpectating) {
const positions = Array.from(updatedStates.entries()).map(([id, state]) => ({
cardId: id,
x: state.x,
y: state.y,
rotation: state.rotation,
zIndex: state.zIndex,
}))
updateCardPositions(positions)
}
return newStates
})
}
dragStateRef.current = null
setDraggingCardId(null)
@@ -516,98 +889,18 @@ export function PlayingPhaseDrag() {
state.correctOrder[index]?.id === card.id &&
state.correctOrder[index + 1]?.id === inferredSequence[index + 1].id
// Calculate arrow position (from center of current card to center of next card)
const fromX = currentCard.x + 70 // 70 = half of card width (140px)
const fromY = currentCard.y + 90 // 90 = half of card height (180px)
const toX = nextCard.x + 70
const toY = nextCard.y + 90
// Calculate angle and distance
const dx = toX - fromX
const dy = toY - fromY
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
const distance = Math.sqrt(dx * dx + dy * dy)
// Don't draw arrow if cards are too close (overlapping or very near)
if (distance < 80) return null
return (
<div
<AnimatedArrow
key={`arrow-${card.id}-${inferredSequence[index + 1].id}`}
style={{
position: 'absolute',
left: `${fromX}px`,
top: `${fromY}px`,
width: `${distance}px`,
height: isCorrectConnection ? '4px' : '3px',
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
pointerEvents: 'none',
zIndex: 0, // Behind cards
animation: isCorrectConnection
? 'correctArrowGlow 1.5s ease-in-out infinite'
: 'none',
}}
>
{/* Arrow line */}
<div
style={{
width: '100%',
height: '100%',
background: isCorrectConnection
? 'linear-gradient(90deg, rgba(34, 197, 94, 0.7) 0%, rgba(34, 197, 94, 0.9) 100%)'
: 'linear-gradient(90deg, rgba(251, 146, 60, 0.6) 0%, rgba(251, 146, 60, 0.8) 100%)',
position: 'relative',
}}
>
{/* Arrow head */}
<div
style={{
position: 'absolute',
right: '-8px',
top: '50%',
width: '0',
height: '0',
borderLeft: isCorrectConnection
? '10px solid rgba(34, 197, 94, 0.9)'
: '10px solid rgba(251, 146, 60, 0.8)',
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
transform: 'translateY(-50%)',
}}
/>
{/* Sequence number badge */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
background: isCorrectConnection
? 'rgba(34, 197, 94, 0.95)'
: 'rgba(251, 146, 60, 0.95)',
color: 'white',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
border: '2px solid white',
boxShadow: isCorrectConnection
? '0 0 12px rgba(34, 197, 94, 0.6)'
: '0 2px 4px rgba(0,0,0,0.2)',
animation: isCorrectConnection
? 'correctBadgePulse 1.5s ease-in-out infinite'
: 'none',
}}
>
{index + 1}
</div>
</div>
</div>
fromCard={currentCard}
toCard={nextCard}
isCorrect={isCorrectConnection}
sequenceNumber={index + 1}
isDragging={!!draggingCardId}
isResizing={isResizing}
viewportWidth={viewportDimensions.width}
viewportHeight={viewportDimensions.height}
/>
)
})}
@@ -622,46 +915,19 @@ export function PlayingPhaseDrag() {
const isDragging = draggingCardId === card.id
return (
<div
<AnimatedCard
key={card.id}
card={card}
cardState={cardState}
isDragging={isDragging}
isResizing={isResizing}
isSpectating={isSpectating}
viewportWidth={viewportDimensions.width}
viewportHeight={viewportDimensions.height}
onPointerDown={(e) => handlePointerDown(e, card.id)}
onPointerMove={(e) => handlePointerMove(e, card.id)}
onPointerUp={(e) => handlePointerUp(e, card.id)}
className={css({
position: 'absolute',
width: '140px',
height: '180px',
cursor: isSpectating ? 'default' : 'grab',
touchAction: 'none',
userSelect: 'none',
transition: isDragging ? 'none' : 'transform 0.2s ease, box-shadow 0.2s ease',
})}
style={{
left: `${cardState.x}px`,
top: `${cardState.y}px`,
transform: `rotate(${cardState.rotation}deg)`,
zIndex: cardState.zIndex,
boxShadow: isDragging
? '0 20px 40px rgba(0, 0, 0, 0.3)'
: '0 4px 8px rgba(0, 0, 0, 0.15)',
}}
>
<div
className={css({
width: '100%',
height: '100%',
background: 'white',
borderRadius: '12px',
border: '3px solid #0369a1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '12px',
boxSizing: 'border-box',
})}
dangerouslySetInnerHTML={{ __html: card.svgContent }}
/>
</div>
/>
)
})}
</div>

2
pnpm-lock.yaml generated
View File

@@ -114,7 +114,7 @@ importers:
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@react-spring/web':
specifier: ^10.0.2
specifier: ^10.0.3
version: 10.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@soroban/abacus-react':
specifier: workspace:*