Skip to content

Commit f8a136f

Browse files
committed
feat: comprehensive visual enhancement system with Spline integration
Visual Components: - ParticleField: Animated particle backgrounds (ambient, energy, network, storm modes) - HoloCard: 3D holographic cards with tilt, sheen, and glow effects - EnergyOrb: Animated metric visualization orbs - PortalTransition: Cinematic page transition effects - ProcessingIndicator: Premium loading animations (spinner, orbital, pulse, DNA, grid) Documentation: - Detailed Spline scene specifications for 5 core scenes - Complete design system reference for Spline - Variable naming conventions and best practices - Performance optimization guidelines
1 parent ee2a467 commit f8a136f

7 files changed

Lines changed: 2193 additions & 0 deletions

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
'use client';
2+
3+
/**
4+
* EnergyOrb
5+
*
6+
* Animated energy orb visualization for displaying metrics.
7+
* Perfect for:
8+
* - Balance displays
9+
* - Energy meters
10+
* - Status indicators
11+
* - Loading states
12+
*
13+
* Can be enhanced with Spline for true 3D depth.
14+
*/
15+
16+
import { useRef, useMemo } from 'react';
17+
import { motion, useAnimation } from 'framer-motion';
18+
import { cn } from '@/lib/utils';
19+
20+
// ============================================================================
21+
// Types
22+
// ============================================================================
23+
24+
interface EnergyOrbProps {
25+
/** Value to display (0-100 for percentages, or any number) */
26+
value?: number;
27+
/** Maximum value (for percentage calculation) */
28+
maxValue?: number;
29+
/** Display label */
30+
label?: string;
31+
/** Value format ('number' | 'percentage' | 'currency') */
32+
format?: 'number' | 'percentage' | 'currency';
33+
/** Size variant */
34+
size?: 'sm' | 'md' | 'lg' | 'xl';
35+
/** Color scheme */
36+
color?: 'cyan' | 'green' | 'purple' | 'gold' | 'red';
37+
/** Animation state */
38+
state?: 'idle' | 'active' | 'charging' | 'depleted';
39+
/** Enable pulse animation */
40+
enablePulse?: boolean;
41+
/** Enable particle ring */
42+
enableParticles?: boolean;
43+
/** Spline scene URL for 3D orb */
44+
splineSceneUrl?: string;
45+
/** Click handler */
46+
onClick?: () => void;
47+
/** Custom className */
48+
className?: string;
49+
}
50+
51+
// Size configurations
52+
const SIZE_CONFIG = {
53+
sm: { container: 80, orb: 50, ring: 70, fontSize: 'text-lg', labelSize: 'text-[10px]' },
54+
md: { container: 120, orb: 70, ring: 100, fontSize: 'text-2xl', labelSize: 'text-xs' },
55+
lg: { container: 160, orb: 100, ring: 140, fontSize: 'text-3xl', labelSize: 'text-sm' },
56+
xl: { container: 200, orb: 130, ring: 180, fontSize: 'text-4xl', labelSize: 'text-base' },
57+
};
58+
59+
// Color configurations
60+
const COLOR_CONFIG = {
61+
cyan: { primary: '#66FCF1', secondary: '#45A29E', glow: 'rgba(102, 252, 241, 0.4)' },
62+
green: { primary: '#03DAC6', secondary: '#00A896', glow: 'rgba(3, 218, 198, 0.4)' },
63+
purple: { primary: '#BF00FF', secondary: '#8B00CC', glow: 'rgba(191, 0, 255, 0.4)' },
64+
gold: { primary: '#FFD700', secondary: '#FFA500', glow: 'rgba(255, 215, 0, 0.4)' },
65+
red: { primary: '#CF6679', secondary: '#B00020', glow: 'rgba(207, 102, 121, 0.4)' },
66+
};
67+
68+
// ============================================================================
69+
// Particle Ring Component
70+
// ============================================================================
71+
72+
function ParticleRing({
73+
size,
74+
color,
75+
particleCount = 12,
76+
animationSpeed = 20,
77+
}: {
78+
size: number;
79+
color: string;
80+
particleCount?: number;
81+
animationSpeed?: number;
82+
}) {
83+
const particles = useMemo(() =>
84+
Array.from({ length: particleCount }, (_, i) => ({
85+
id: i,
86+
angle: (i / particleCount) * 360,
87+
size: 2 + Math.random() * 2,
88+
delay: i * 0.1,
89+
})),
90+
[particleCount]
91+
);
92+
93+
return (
94+
<motion.div
95+
className="absolute inset-0 pointer-events-none"
96+
animate={{ rotate: 360 }}
97+
transition={{ duration: animationSpeed, repeat: Infinity, ease: 'linear' }}
98+
>
99+
{particles.map((particle) => (
100+
<motion.div
101+
key={particle.id}
102+
className="absolute rounded-full"
103+
style={{
104+
width: particle.size,
105+
height: particle.size,
106+
backgroundColor: color,
107+
boxShadow: `0 0 ${particle.size * 2}px ${color}`,
108+
left: '50%',
109+
top: '50%',
110+
transform: `rotate(${particle.angle}deg) translateY(-${size / 2}px)`,
111+
}}
112+
animate={{
113+
opacity: [0.3, 1, 0.3],
114+
scale: [0.8, 1.2, 0.8],
115+
}}
116+
transition={{
117+
duration: 2,
118+
repeat: Infinity,
119+
delay: particle.delay,
120+
}}
121+
/>
122+
))}
123+
</motion.div>
124+
);
125+
}
126+
127+
// ============================================================================
128+
// Concentric Rings Component
129+
// ============================================================================
130+
131+
function ConcentricRings({ size, color, state }: { size: number; color: string; state: string }) {
132+
const rings = [0.6, 0.75, 0.9];
133+
134+
return (
135+
<>
136+
{rings.map((scale, i) => (
137+
<motion.div
138+
key={i}
139+
className="absolute rounded-full border"
140+
style={{
141+
width: size * scale,
142+
height: size * scale,
143+
left: '50%',
144+
top: '50%',
145+
transform: 'translate(-50%, -50%)',
146+
borderColor: `${color}${20 + i * 10}`,
147+
}}
148+
animate={state === 'charging' ? {
149+
scale: [1, 1.05, 1],
150+
opacity: [0.3, 0.6, 0.3],
151+
} : undefined}
152+
transition={{
153+
duration: 2,
154+
repeat: Infinity,
155+
delay: i * 0.3,
156+
}}
157+
/>
158+
))}
159+
</>
160+
);
161+
}
162+
163+
// ============================================================================
164+
// Core Orb Component
165+
// ============================================================================
166+
167+
function CoreOrb({
168+
size,
169+
colors,
170+
state,
171+
percentage,
172+
}: {
173+
size: number;
174+
colors: typeof COLOR_CONFIG.cyan;
175+
state: string;
176+
percentage: number;
177+
}) {
178+
return (
179+
<motion.div
180+
className="absolute rounded-full"
181+
style={{
182+
width: size,
183+
height: size,
184+
left: '50%',
185+
top: '50%',
186+
transform: 'translate(-50%, -50%)',
187+
background: `
188+
radial-gradient(
189+
circle at 30% 30%,
190+
${colors.primary}40 0%,
191+
${colors.secondary}20 50%,
192+
${colors.primary}10 100%
193+
)
194+
`,
195+
boxShadow: `
196+
0 0 ${size * 0.3}px ${colors.glow},
197+
inset 0 0 ${size * 0.2}px ${colors.primary}20
198+
`,
199+
}}
200+
animate={
201+
state === 'active' ? {
202+
boxShadow: [
203+
`0 0 ${size * 0.3}px ${colors.glow}, inset 0 0 ${size * 0.2}px ${colors.primary}20`,
204+
`0 0 ${size * 0.5}px ${colors.glow}, inset 0 0 ${size * 0.3}px ${colors.primary}40`,
205+
`0 0 ${size * 0.3}px ${colors.glow}, inset 0 0 ${size * 0.2}px ${colors.primary}20`,
206+
],
207+
} : state === 'depleted' ? {
208+
opacity: [1, 0.5, 1],
209+
} : undefined
210+
}
211+
transition={{
212+
duration: 2,
213+
repeat: Infinity,
214+
ease: 'easeInOut',
215+
}}
216+
>
217+
{/* Fill level indicator */}
218+
<div
219+
className="absolute bottom-0 left-0 right-0 overflow-hidden rounded-full"
220+
style={{
221+
height: `${percentage}%`,
222+
background: `linear-gradient(to top, ${colors.primary}60, ${colors.primary}20)`,
223+
transition: 'height 0.5s ease-out',
224+
}}
225+
/>
226+
227+
{/* Highlight */}
228+
<div
229+
className="absolute rounded-full"
230+
style={{
231+
width: '30%',
232+
height: '30%',
233+
top: '15%',
234+
left: '20%',
235+
background: `radial-gradient(circle, ${colors.primary}40, transparent)`,
236+
}}
237+
/>
238+
</motion.div>
239+
);
240+
}
241+
242+
// ============================================================================
243+
// Value Display Component
244+
// ============================================================================
245+
246+
function ValueDisplay({
247+
value,
248+
format,
249+
label,
250+
fontSize,
251+
labelSize,
252+
color,
253+
}: {
254+
value: number;
255+
format: string;
256+
label?: string;
257+
fontSize: string;
258+
labelSize: string;
259+
color: string;
260+
}) {
261+
const formattedValue = useMemo(() => {
262+
switch (format) {
263+
case 'percentage':
264+
return `${Math.round(value)}%`;
265+
case 'currency':
266+
return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : value.toFixed(1);
267+
default:
268+
return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : Math.round(value).toString();
269+
}
270+
}, [value, format]);
271+
272+
return (
273+
<div className="absolute inset-0 flex flex-col items-center justify-center z-10">
274+
<motion.span
275+
className={cn('font-mono font-bold', fontSize)}
276+
style={{ color }}
277+
initial={{ opacity: 0, scale: 0.8 }}
278+
animate={{ opacity: 1, scale: 1 }}
279+
key={formattedValue}
280+
>
281+
{formattedValue}
282+
</motion.span>
283+
{label && (
284+
<span
285+
className={cn('font-display uppercase tracking-wider text-text-tertiary', labelSize)}
286+
>
287+
{label}
288+
</span>
289+
)}
290+
</div>
291+
);
292+
}
293+
294+
// ============================================================================
295+
// Main Component
296+
// ============================================================================
297+
298+
export function EnergyOrb({
299+
value = 0,
300+
maxValue = 100,
301+
label,
302+
format = 'number',
303+
size = 'md',
304+
color = 'cyan',
305+
state = 'idle',
306+
enablePulse = true,
307+
enableParticles = true,
308+
splineSceneUrl,
309+
onClick,
310+
className,
311+
}: EnergyOrbProps) {
312+
const config = SIZE_CONFIG[size];
313+
const colors = COLOR_CONFIG[color];
314+
const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100));
315+
316+
return (
317+
<motion.div
318+
className={cn(
319+
'relative flex items-center justify-center cursor-pointer',
320+
className
321+
)}
322+
style={{
323+
width: config.container,
324+
height: config.container,
325+
}}
326+
onClick={onClick}
327+
whileHover={{ scale: 1.05 }}
328+
whileTap={{ scale: 0.95 }}
329+
>
330+
{/* Outer glow */}
331+
{enablePulse && (
332+
<motion.div
333+
className="absolute inset-0 rounded-full"
334+
style={{
335+
background: `radial-gradient(circle, ${colors.glow}, transparent 70%)`,
336+
}}
337+
animate={{
338+
opacity: [0.3, 0.6, 0.3],
339+
scale: [1, 1.1, 1],
340+
}}
341+
transition={{
342+
duration: 3,
343+
repeat: Infinity,
344+
ease: 'easeInOut',
345+
}}
346+
/>
347+
)}
348+
349+
{/* Concentric rings */}
350+
<ConcentricRings size={config.ring} color={colors.primary} state={state} />
351+
352+
{/* Particle ring */}
353+
{enableParticles && (
354+
<ParticleRing
355+
size={config.ring}
356+
color={colors.primary}
357+
animationSpeed={state === 'charging' ? 10 : 20}
358+
/>
359+
)}
360+
361+
{/* Core orb */}
362+
<CoreOrb
363+
size={config.orb}
364+
colors={colors}
365+
state={state}
366+
percentage={percentage}
367+
/>
368+
369+
{/* Value display */}
370+
<ValueDisplay
371+
value={value}
372+
format={format}
373+
label={label}
374+
fontSize={config.fontSize}
375+
labelSize={config.labelSize}
376+
color={colors.primary}
377+
/>
378+
</motion.div>
379+
);
380+
}
381+
382+
export default EnergyOrb;

0 commit comments

Comments
 (0)