From 4d8e873358271fe3fd50b228aea8277e20aa5966 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 23 Oct 2025 15:15:37 -0500 Subject: [PATCH] feat(card-sorting): add bezier curves to connecting arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/PlayingPhaseDrag.tsx | 173 ++++++++++++------ 1 file changed, 121 insertions(+), 52 deletions(-) diff --git a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx index 3c79404a..1025d88d 100644 --- a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx @@ -193,74 +193,143 @@ function AnimatedArrow({ // Don't draw arrow if cards are too close 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 ( `${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)`), + left: 0, + top: 0, + width: '100%', + height: '100%', pointerEvents: 'none', zIndex: 0, - animation: isCorrect ? 'correctArrowGlow 1.5s ease-in-out infinite' : 'none', }} > - {/* Arrow line */} -
- {/* Arrow head */} -
{ + // 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={{ - 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%)', + filter: isCorrect ? 'drop-shadow(0 0 8px rgba(34, 197, 94, 0.6))' : 'none', }} /> - {/* Sequence number badge */} - `translate(-50%, -50%) rotate(${-val}deg)`), - 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} - -
+ + {/* Arrowhead */} + { + // Recalculate end position and angle + const tx = fromX + Math.cos((ang * Math.PI) / 180) * distance + const ty = fromY + Math.sin((ang * Math.PI) / 180) * distance + const mx = (fromX + tx) / 2 + const my = (fromY + 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 + const tangX = tx - cx + const tangY = ty - cy + const aAngle = Math.atan2(tangY, tangX) + + // Arrow points relative to tip + const tipX = tx + const tipY = ty + const baseX = tipX - Math.cos(aAngle) * 10 + 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}` + return `${tipX},${tipY} ${left} ${right}` + })} + fill={isCorrect ? 'rgba(34, 197, 94, 0.9)' : 'rgba(251, 146, 60, 0.8)'} + /> + + + {/* Sequence number badge */} + { + 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} + ) }