feat(card-sorting): add bezier curves to connecting arrows

Replace straight arrow lines with smooth quadratic bezier curves:

- Use SVG path with quadratic bezier (Q command)
- Calculate control point perpendicular to line, offset by 30px
- Animate path with react-spring for smooth transitions
- Position arrowhead at curve endpoint with correct tangent angle
- Position sequence number badge at curve's control point (apex)
- Add drop shadow to correct (green) arrows for extra polish

Arrows now follow elegant curved paths between cards instead of
straight lines, giving the game a more polished and playful feel.
The curves are automatically calculated and animate smoothly as
cards move around.

🤖 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 15:15:37 -05:00
parent dc1cbd81f8
commit 4d8e873358

View File

@@ -193,74 +193,143 @@ function AnimatedArrow({
// Don't draw arrow if cards are too close // Don't draw arrow if cards are too close
if (distance < 80) return null if (distance < 80) return null
// Calculate control point for bezier curve (perpendicular to line, offset by 30px)
const midX = (fromX + toX) / 2
const midY = (fromY + toY) / 2
const perpAngle = angle + 90 // Perpendicular to the line
const curveOffset = 30 // How much to curve (in pixels)
const controlX = midX + Math.cos((perpAngle * Math.PI) / 180) * curveOffset
const controlY = midY + Math.sin((perpAngle * Math.PI) / 180) * curveOffset
// Calculate arrowhead position and angle at the end of the curve
// For a quadratic bezier, the tangent at t=1 is: 2*(P2 - P1)
const tangentX = toX - controlX
const tangentY = toY - controlY
const arrowAngle = Math.atan2(tangentY, tangentX) * (180 / Math.PI)
return ( return (
<animated.div <animated.div
style={{ style={{
position: 'absolute', position: 'absolute',
left: springProps.fromX.to((val) => `${val}px`), left: 0,
top: springProps.fromY.to((val) => `${val}px`), top: 0,
width: springProps.distance.to((val) => `${val}px`), width: '100%',
height: isCorrect ? '4px' : '3px', height: '100%',
transformOrigin: '0 50%',
transform: springProps.angle.to((val) => `rotate(${val}deg)`),
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 0, zIndex: 0,
animation: isCorrect ? 'correctArrowGlow 1.5s ease-in-out infinite' : 'none',
}} }}
> >
{/* Arrow line */} <svg
<div
style={{ style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
background: isCorrect overflow: 'visible',
? '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 */} {/* Curved line using quadratic bezier */}
<div <animated.path
d={springProps.fromX.to(
(fx, fy, dist, ang) => {
// Recalculate curve with animated values
const tx = fx + Math.cos((ang * Math.PI) / 180) * dist
const ty = fy + Math.sin((ang * Math.PI) / 180) * dist
const mx = (fx + tx) / 2
const my = (fy + ty) / 2
const perpAng = ang + 90
const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset
const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset
return `M ${fx} ${fy} Q ${cx} ${cy} ${tx} ${ty}`
},
springProps.fromY,
springProps.distance,
springProps.angle
)}
stroke={isCorrect ? 'rgba(34, 197, 94, 0.8)' : 'rgba(251, 146, 60, 0.7)'}
strokeWidth={isCorrect ? '4' : '3'}
fill="none"
style={{ style={{
position: 'absolute', filter: isCorrect ? 'drop-shadow(0 0 8px rgba(34, 197, 94, 0.6))' : 'none',
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 */}
<animated.div {/* Arrowhead */}
style={{ <animated.polygon
position: 'absolute', points={springProps.angle.to((ang) => {
left: '50%', // Recalculate end position and angle
top: '50%', const tx = fromX + Math.cos((ang * Math.PI) / 180) * distance
// Counter-rotate to keep number upright, plus center translation const ty = fromY + Math.sin((ang * Math.PI) / 180) * distance
transform: springProps.angle.to((val) => `translate(-50%, -50%) rotate(${-val}deg)`), const mx = (fromX + tx) / 2
background: isCorrect ? 'rgba(34, 197, 94, 0.95)' : 'rgba(251, 146, 60, 0.95)', const my = (fromY + ty) / 2
color: 'white', const perpAng = ang + 90
borderRadius: '50%', const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset
width: '24px', const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset
height: '24px', const tangX = tx - cx
display: 'flex', const tangY = ty - cy
alignItems: 'center', const aAngle = Math.atan2(tangY, tangX)
justifyContent: 'center',
fontSize: '12px', // Arrow points relative to tip
fontWeight: 'bold', const tipX = tx
border: '2px solid white', const tipY = ty
boxShadow: isCorrect ? '0 0 12px rgba(34, 197, 94, 0.6)' : '0 2px 4px rgba(0,0,0,0.2)', const baseX = tipX - Math.cos(aAngle) * 10
animation: isCorrect ? 'correctBadgePulse 1.5s ease-in-out infinite' : 'none', const baseY = tipY - Math.sin(aAngle) * 10
}} const left = `${baseX + Math.cos(aAngle + Math.PI / 2) * 6},${baseY + Math.sin(aAngle + Math.PI / 2) * 6}`
> const right = `${baseX + Math.cos(aAngle - Math.PI / 2) * 6},${baseY + Math.sin(aAngle - Math.PI / 2) * 6}`
{sequenceNumber} return `${tipX},${tipY} ${left} ${right}`
</animated.div> })}
</div> fill={isCorrect ? 'rgba(34, 197, 94, 0.9)' : 'rgba(251, 146, 60, 0.8)'}
/>
</svg>
{/* Sequence number badge */}
<animated.div
style={{
position: 'absolute',
left: springProps.fromX.to(
(fx, fy, dist, ang) => {
const tx = fx + Math.cos((ang * Math.PI) / 180) * dist
const mx = (fx + tx) / 2
const perpAng = ang + 90
const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset
return `${cx}px`
},
springProps.fromY,
springProps.distance,
springProps.angle
),
top: springProps.fromY.to(
(fy, fx, dist, ang) => {
const tx = fx + Math.cos((ang * Math.PI) / 180) * dist
const ty = fy + Math.sin((ang * Math.PI) / 180) * dist
const my = (fy + ty) / 2
const perpAng = ang + 90
const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset
return `${cy}px`
},
springProps.fromX,
springProps.distance,
springProps.angle
),
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}
</animated.div>
</animated.div> </animated.div>
) )
} }