From f83230d7e9a29ce5a6cb9973820233757dd46921 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Mar 2026 11:05:37 +0800 Subject: [PATCH] feat: add particle motion to agent showcase banner --- .../agents/AgentWorkflowParticleScatter.tsx | 208 ++++++++++++++++++ .../agents/AgentWorkflowShowcaseBanner.tsx | 81 ++++--- 2 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 components/agents/AgentWorkflowParticleScatter.tsx diff --git a/components/agents/AgentWorkflowParticleScatter.tsx b/components/agents/AgentWorkflowParticleScatter.tsx new file mode 100644 index 000000000..92e708d37 --- /dev/null +++ b/components/agents/AgentWorkflowParticleScatter.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +type AgentWorkflowParticleScatterProps = { + className?: string; + density?: "low" | "medium" | "high"; +}; + +type Particle = { + x: number; + y: number; + radius: number; + speed: number; + drift: number; + alpha: number; + hue: number; + phase: number; + swing: number; + depth: number; +}; + +const PARTICLE_DENSITY_MULTIPLIER = { + low: 0.75, + medium: 1, + high: 1.35, +} as const; + +function randomBetween(min: number, max: number) { + return min + Math.random() * (max - min); +} + +export function AgentWorkflowParticleScatter({ + className, + density = "medium", +}: AgentWorkflowParticleScatterProps) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const context = canvas.getContext("2d"); + if (!context) { + return; + } + + const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + let width = 0; + let height = 0; + let animationFrameId = 0; + let particles: Particle[] = []; + + const buildParticle = (): Particle => { + const depth = randomBetween(0.65, 1.15); + const spread = width * randomBetween(0.16, 0.48); + const originX = width / 2 + randomBetween(-spread, spread); + const originY = height * randomBetween(0.56, 1.06); + + return { + x: originX, + y: originY, + radius: randomBetween(0.9, 2.8) * depth, + speed: randomBetween(0.08, 0.34) * depth, + drift: randomBetween(0.12, 0.38) * (Math.random() > 0.5 ? 1 : -1), + alpha: randomBetween(0.16, 0.84), + hue: Math.random() > 0.7 ? 0 : 160, + phase: randomBetween(0, Math.PI * 2), + swing: randomBetween(10, 42), + depth, + }; + }; + + const resetParticle = (particle: Particle, respawnNearBottom = false) => { + const next = buildParticle(); + particle.x = next.x; + particle.y = respawnNearBottom ? height * randomBetween(0.72, 1.04) : next.y; + particle.radius = next.radius; + particle.speed = next.speed; + particle.drift = next.drift; + particle.alpha = next.alpha; + particle.hue = next.hue; + particle.phase = next.phase; + particle.swing = next.swing; + particle.depth = next.depth; + }; + + const resize = () => { + const bounds = canvas.getBoundingClientRect(); + const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + width = Math.max(1, bounds.width); + height = Math.max(1, bounds.height); + canvas.width = Math.round(width * pixelRatio); + canvas.height = Math.round(height * pixelRatio); + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + + const baseCount = Math.round((width / 12) * PARTICLE_DENSITY_MULTIPLIER[density]); + particles = Array.from( + { length: Math.max(56, Math.min(220, baseCount)) }, + () => buildParticle() + ); + }; + + const drawGlow = (x: number, y: number, radius: number, alpha: number, hue: number) => { + const gradient = context.createRadialGradient(x, y, 0, x, y, radius * 5.5); + if (hue === 160) { + gradient.addColorStop(0, `rgba(110, 255, 217, ${alpha})`); + gradient.addColorStop(0.35, `rgba(26, 214, 168, ${alpha * 0.7})`); + gradient.addColorStop(1, "rgba(26, 214, 168, 0)"); + } else { + gradient.addColorStop(0, `rgba(255, 255, 255, ${alpha})`); + gradient.addColorStop(0.42, `rgba(209, 255, 244, ${alpha * 0.45})`); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + } + context.fillStyle = gradient; + context.beginPath(); + context.arc(x, y, radius * 5.5, 0, Math.PI * 2); + context.fill(); + }; + + const render = (time: number) => { + context.clearRect(0, 0, width, height); + + const bottomGlow = context.createRadialGradient( + width / 2, + height * 0.92, + 0, + width / 2, + height * 0.92, + width * 0.42 + ); + bottomGlow.addColorStop(0, "rgba(19, 221, 179, 0.18)"); + bottomGlow.addColorStop(0.5, "rgba(19, 221, 179, 0.08)"); + bottomGlow.addColorStop(1, "rgba(19, 221, 179, 0)"); + context.fillStyle = bottomGlow; + context.fillRect(0, 0, width, height); + + particles.forEach((particle) => { + particle.phase += 0.012; + particle.y -= particle.speed; + particle.x += Math.sin(time * 0.00032 + particle.phase) * particle.drift; + + const waveOffset = + Math.sin(time * 0.001 + particle.phase * 2.4) * particle.swing * 0.018; + const pulseAlpha = + particle.alpha * (0.7 + 0.3 * Math.sin(time * 0.0016 + particle.phase)); + const renderX = particle.x + waveOffset; + const renderY = particle.y; + + if (renderY < -24 || renderX < -32 || renderX > width + 32) { + resetParticle(particle, true); + } + + drawGlow(renderX, renderY, particle.radius, pulseAlpha * 0.68, particle.hue); + + context.beginPath(); + context.fillStyle = + particle.hue === 160 + ? `rgba(155, 255, 230, ${pulseAlpha})` + : `rgba(255, 255, 255, ${pulseAlpha})`; + context.arc(renderX, renderY, particle.radius, 0, Math.PI * 2); + context.fill(); + }); + + if (!reducedMotionQuery.matches) { + animationFrameId = window.requestAnimationFrame(render); + } + }; + + resize(); + + const handleResize = () => { + resize(); + }; + + window.addEventListener("resize", handleResize); + + if (reducedMotionQuery.matches) { + render(0); + } else { + animationFrameId = window.requestAnimationFrame(render); + } + + return () => { + window.removeEventListener("resize", handleResize); + if (animationFrameId) { + window.cancelAnimationFrame(animationFrameId); + } + }; + }, [density]); + + return ( +
+ +
+ ); +} diff --git a/components/agents/AgentWorkflowShowcaseBanner.tsx b/components/agents/AgentWorkflowShowcaseBanner.tsx index 963cef951..27c1b150d 100644 --- a/components/agents/AgentWorkflowShowcaseBanner.tsx +++ b/components/agents/AgentWorkflowShowcaseBanner.tsx @@ -2,6 +2,7 @@ import { motion } from "framer-motion"; import { ArrowRight, ImageIcon } from "lucide-react"; +import { AgentWorkflowParticleScatter } from "@/components/agents/AgentWorkflowParticleScatter"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { defaultAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config"; @@ -33,44 +34,55 @@ export function AgentWorkflowShowcaseBanner({ return (
-

{resolved.title}

-

{resolved.subtitle}

- {resolvedActionLabel || secondaryActionLabel ? ( -
- {resolvedActionLabel ? ( - - ) : null} - {secondaryActionLabel ? ( - - ) : null} -
- ) : null} + +
+
+ +
+

{resolved.title}

+

{resolved.subtitle}

+ {resolvedActionLabel || secondaryActionLabel ? ( +
+ {resolvedActionLabel ? ( + + ) : null} + {secondaryActionLabel ? ( + + ) : null} +
+ ) : null} +
{shouldRenderMedia ? ( @@ -104,6 +116,7 @@ export function AgentWorkflowShowcaseBanner({
+ {resolved.overlayText ? (