Skip to content

Commit 9534f3a

Browse files
committed
improve hero animation
1 parent ee9a0e4 commit 9534f3a

22 files changed

Lines changed: 922 additions & 57 deletions

assets/cover.js

Lines changed: 313 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Source code licensed under Apache License 2.0.
22
// Copyright © 2017 William Ngan. (https://github.com/williamngan/pts)
33

4-
window.demoDescription = "In a field of points that revolves around a center, draw a perpendicular line from each point to a path.";
4+
window.demoDescription = "In a field of bouncing particles, rotate a centered path that nudges points as it sweeps past.";
55

66
(function() {
77

@@ -12,49 +12,331 @@ window.demoDescription = "In a field of points that revolves around a center, dr
1212

1313
//// Demo code ---
1414

15-
var pts = new Group();
16-
var timeOutId = -1;
15+
var particles = new Group();
16+
var world = null;
17+
var pointerParticle = null;
18+
var pointerActive = false;
19+
var pointerPosition = new Pt();
20+
var pointerDirection = new Pt();
21+
var center = new Pt();
22+
var dividerDirection = new Pt();
23+
var previousDividerDirection = new Pt();
24+
var nextDividerDirection = new Pt();
25+
var particleOffset = new Pt();
26+
var dividerImpulse = new Pt();
27+
var canvasOffset = new Pt();
28+
var resizeTimeoutId = -1;
1729
var header = null;
30+
var headerOffset = null;
31+
var connectorFadeDistance = 1;
32+
var collisionRadius = 20;
33+
var pointerRadius = 30;
34+
var initialDividerRotation = 30 * Const.one_degree;
35+
var flashDuration = 500;
36+
var flashDebounceDuration = 30;
37+
var flashMinRadius = 4;
38+
var flashMaxRadius = 6;
39+
var particleMaxOpacity = 0.6;
40+
var dividerHitStrength = 0.25;
41+
var dividerMaxImpulse = 6;
42+
var dividerRotationThreshold = 0.0005;
43+
var baseMaxParticles = 200;
44+
var particleCountMultiplier = 1.3;
45+
var particleColors = ["#f03", "#09f", "#0c6"];
46+
var particleColorGroups = particleColors.map( () => new Group() );
47+
var flashingParticles = new Group();
48+
var connectorLine = new Group( new Pt(), new Pt() );
49+
var animationTime = 0;
50+
51+
class FlashParticle extends Particle {
52+
constructor( point ) {
53+
super( point );
54+
this.flashUntil = 0;
55+
this.flashLastHitAt = -Infinity;
56+
this.flashRadius = flashMinRadius;
57+
}
58+
59+
triggerFlash() {
60+
if (this === pointerParticle) return;
61+
62+
var timeSinceLastHit = animationTime - this.flashLastHitAt;
63+
this.flashLastHitAt = animationTime;
64+
if (
65+
this.flashUntil > animationTime ||
66+
timeSinceLastHit < flashDebounceDuration
67+
) return;
68+
69+
this.flashRadius = Num.randomRange( flashMinRadius, flashMaxRadius );
70+
this.flashUntil = animationTime + flashDuration;
71+
}
72+
73+
collide( other, damping ) {
74+
if (
75+
!pointerActive &&
76+
(this === pointerParticle || other === pointerParticle)
77+
) return;
78+
79+
var dx = this[0] - other[0];
80+
var dy = this[1] - other[1];
81+
var collisionDistance = this.radius + other.radius;
82+
if (dx * dx + dy * dy >= collisionDistance * collisionDistance) return;
83+
84+
this.triggerFlash();
85+
other.triggerFlash();
86+
super.collide( other, damping );
87+
}
88+
}
89+
90+
var hitParticlesWithDivider = (rotation) => {
91+
if (!world || Math.abs(rotation) < dividerRotationThreshold) return;
92+
93+
for (var i = 1, len = particles.length; i < len; i++) {
94+
var particle = particles[i];
95+
var offset = particleOffset.to( particle ).subtract( center );
96+
var previousDistance = offset.$cross2D( previousDividerDirection );
97+
var distance = offset.$cross2D( dividerDirection );
98+
var crossedDivider = previousDistance * distance <= 0;
99+
var withinCollisionRadius = Math.min(
100+
Math.abs(previousDistance),
101+
Math.abs(distance)
102+
) <= particle.radius;
103+
104+
if (crossedDivider || withinCollisionRadius) {
105+
var impulse = Num.clamp(
106+
offset.dot( dividerDirection ) * rotation * dividerHitStrength,
107+
-dividerMaxImpulse,
108+
dividerMaxImpulse
109+
);
110+
if (Math.abs(impulse) <= 0.01) continue;
111+
112+
dividerImpulse.to( -dividerDirection.y, dividerDirection.x ).multiply( impulse );
113+
particle.hit( dividerImpulse );
114+
particle.triggerFlash();
115+
}
116+
}
117+
};
118+
119+
var updateDivider = (hitParticles = false) => {
120+
var hasPreviousDirection = dividerDirection.magnitudeSq() > 0;
121+
if (hasPreviousDirection) previousDividerDirection.to( dividerDirection );
122+
123+
var direction = pointerDirection.to( pointerPosition ).subtract( center );
124+
var pointerDistanceSq = direction.magnitudeSq();
125+
if (pointerDistanceSq < Const.epsilon) {
126+
// The pointer angle is undefined at center. Keep the current divider
127+
// there, using 30° only for its initial orientation.
128+
if (hasPreviousDirection) {
129+
nextDividerDirection.to( dividerDirection );
130+
} else {
131+
nextDividerDirection.toAngle( initialDividerRotation, 1 );
132+
}
133+
} else {
134+
nextDividerDirection
135+
.to( -direction.y, direction.x )
136+
.unit( Math.sqrt(pointerDistanceSq) );
137+
}
138+
139+
var rotation = 0;
140+
if (hasPreviousDirection) {
141+
if (previousDividerDirection.dot( nextDividerDirection ) < 0) {
142+
nextDividerDirection.multiply( -1 );
143+
}
144+
rotation = Math.atan2(
145+
previousDividerDirection.$cross2D( nextDividerDirection ),
146+
previousDividerDirection.dot( nextDividerDirection )
147+
);
148+
}
149+
150+
dividerDirection.to( nextDividerDirection );
151+
if (hitParticles) hitParticlesWithDivider( rotation );
152+
};
153+
154+
var movePointer = (px, py) => {
155+
pointerPosition.to( px, py );
156+
updateDivider( true );
157+
pointerActive = true;
158+
if (pointerParticle) pointerParticle.position = pointerPosition;
159+
};
160+
161+
var deactivatePointer = () => {
162+
pointerActive = false;
163+
};
164+
165+
var movePointerOverHeader = (event) => {
166+
// A fixed overlay can receive a zero-delta move when it mounts beneath a
167+
// stationary cursor. Preserve the 30° startup state until the cursor moves.
168+
if (!pointerActive && event.movementX === 0 && event.movementY === 0) return;
169+
170+
var px = event.pageX - canvasOffset.x;
171+
var py = event.pageY - canvasOffset.y;
172+
if (
173+
!Num.within(px, 0, space.width) ||
174+
!Num.within(py, 0, space.height)
175+
) return;
176+
movePointer( px, py );
177+
};
178+
179+
var updateHeaderPosition = () => {
180+
if (!header) return;
181+
182+
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
183+
var nextHeaderOffset = Math.min( 0, space.height - 150 - scrollTop );
184+
if (nextHeaderOffset !== headerOffset) {
185+
header.style.transform = `translateY(${nextHeaderOffset}px)`;
186+
headerOffset = nextHeaderOffset;
187+
}
188+
};
189+
190+
var updateCanvasGeometry = (bound) => {
191+
canvasOffset.to( bound[0] );
192+
center = space.center;
193+
connectorFadeDistance = Math.max( center.x, 1 );
194+
};
195+
196+
var calculateParticleCount = () => {
197+
var collisionDiameter = collisionRadius * 2;
198+
var baseParticleCount = Num.clamp(
199+
Math.floor( space.width * space.height / (collisionDiameter * collisionDiameter * 3) ),
200+
32,
201+
baseMaxParticles
202+
);
203+
return Math.round( baseParticleCount * particleCountMultiplier );
204+
};
205+
206+
var addParticle = (particle) => {
207+
var colorIndex = particles.length % particleColors.length;
208+
particleColorGroups[colorIndex].push( particle );
209+
particles.push( particle );
210+
world.add( particle );
211+
};
212+
213+
var createWorld = () => {
214+
var bound = space.innerBound;
215+
world = new World( bound, 1, 0 );
216+
world.damping = 1;
217+
218+
var particleCount = calculateParticleCount();
219+
var points = Create.distributeRandom( bound, particleCount - 1 );
220+
var initialImpulse = new Pt();
221+
particles.length = 0;
222+
flashingParticles.length = 0;
223+
for (var colorIndex = 0; colorIndex < particleColorGroups.length; colorIndex++) {
224+
particleColorGroups[colorIndex].length = 0;
225+
}
226+
227+
pointerParticle = new FlashParticle( pointerPosition ).size( pointerRadius );
228+
pointerParticle.lock = true;
229+
addParticle( pointerParticle );
230+
231+
for (var i = 0, len = points.length; i < len; i++) {
232+
var angle = Num.randomRange( 0, Const.two_pi );
233+
var speed = Num.randomRange( 2.5, 5 );
234+
var particle = new FlashParticle( points[i] ).size( collisionRadius );
235+
particle.hit( initialImpulse.toAngle(angle, speed) );
236+
addParticle( particle );
237+
}
238+
};
18239

19240

20241
space.add({
21242

22-
// creatr 200 random points
23-
start:( bound ) => {
24-
pts = Create.distributeRandom( space.innerBound, 200 );
243+
start:(bound) => {
244+
updateCanvasGeometry( bound );
245+
pointerPosition.to( center );
246+
updateDivider();
247+
createWorld();
25248
header = document.getElementById("header");
249+
if (header) {
250+
space.bindCanvas( "pointermove", movePointerOverHeader, {}, header );
251+
space.bindCanvas( "pointerleave", deactivatePointer, {}, header );
252+
space.bindDoc( "scroll", updateHeaderPosition, {passive: true} );
253+
updateHeaderPosition();
254+
}
26255
},
27256

28257
animate: (time, ftime) => {
29-
// make a line and turn it into an "op" (see the guide on Op for more)
30-
let perpend = new Group( space.center.$subtract(0.1), space.pointer ).op( Line.perpendicularFromPt );
31-
pts.rotate2D( 0.0005, space.center );
32-
33-
pts.forEach( (p, i) => {
34-
// for each point, find the perpendicular to the line
35-
let lp = perpend( p );
36-
var ratio = Math.min( 1, 1 - lp.$subtract(p).magnitude()/(space.size.x/2) );
37-
form.stroke(`rgba(255,255,255,${ratio}`, ratio*2).line( [ p, lp ] );
38-
form.fillOnly( ["#f03", "#09f", "#0c6"][i%3] ).point( p, 1.5, "circle" );
39-
});
40-
41-
// header position
42-
if (header) {
43-
let top = window.pageYOffset || document.documentElement.scrollTop;
44-
let dp = top - space.size.y + 150;
45-
if (dp > 0) {
46-
header.style.top = `${dp * -1}px`;
47-
} else {
48-
header.style.top = "0px";
258+
animationTime = time;
259+
world.update( ftime );
260+
// A locked particle can be displaced by the final collision substep. Draw it at
261+
// the actual pointer position while preserving the collision response on others.
262+
pointerParticle.to( pointerPosition );
263+
264+
// Anchor the divider at center and keep it perpendicular to the pointer angle.
265+
// Reuse a Pt as the offset/projection buffer to keep this per-frame loop allocation-free.
266+
flashingParticles.length = 0;
267+
268+
form.strokeOnly( "#fff" );
269+
for (var i = 0, len = particles.length; i < len; i++) {
270+
var p = particles[i];
271+
var offset = particleOffset.to( p ).subtract( center );
272+
var distance = Math.abs( offset.$cross2D( dividerDirection ) );
273+
var ratio = Num.clamp( 1 - distance / connectorFadeDistance, 0, 1 );
274+
275+
if (ratio > 0) {
276+
var projectionLength = offset.dot( dividerDirection );
277+
connectorLine[0] = p;
278+
connectorLine[1]
279+
.to( dividerDirection )
280+
.multiply( projectionLength )
281+
.add( center );
282+
form.alpha( ratio ).stroke( "#fff", ratio * 2 ).line( connectorLine );
49283
}
284+
285+
if (p.flashUntil > time) flashingParticles.push( p );
286+
}
287+
288+
form.alpha( particleMaxOpacity );
289+
for (var colorIndex = 0; colorIndex < particleColorGroups.length; colorIndex++) {
290+
form.fillOnly( particleColors[colorIndex] ).points( particleColorGroups[colorIndex], 1.5, "circle" );
50291
}
51292

293+
// Draw flashes last so they sit above the color dots, then shrink them
294+
// concentrically as they fade.
295+
form.fillOnly( "#fff" );
296+
for (var flashIndex = 0; flashIndex < flashingParticles.length; flashIndex++) {
297+
var flashingParticle = flashingParticles[flashIndex];
298+
var flashProgress = Num.clamp(
299+
1 - (flashingParticle.flashUntil - time) / flashDuration,
300+
0,
301+
1
302+
);
303+
var flashLife = 1 - Shaping.quadraticIn( flashProgress );
304+
form.alpha( particleMaxOpacity * flashLife ).point(
305+
flashingParticle,
306+
flashingParticle.flashRadius * flashLife,
307+
"circle"
308+
);
309+
}
310+
form.alpha( 1 );
311+
312+
},
313+
314+
action: (type, px, py, event) => {
315+
// A mouse remains over the canvas after a drag ends. Touch and pen input
316+
// do not, so their drop should remove the collider until the next contact.
317+
if (
318+
type === "out" ||
319+
(type === "drop" && event && event.pointerType !== "mouse")
320+
) {
321+
deactivatePointer();
322+
} else if (type === "move" || type === "drag" || type === "down" || type === "over") {
323+
movePointer( px, py );
324+
}
52325
},
53326

54-
resize: () => {
55-
clearTimeout( timeOutId );
56-
setTimeout( () => {
57-
pts = Create.distributeRandom( space.innerBound, 200 );
327+
resize: (bound) => {
328+
if (!world) return;
329+
updateCanvasGeometry( bound );
330+
world.bound = space.innerBound;
331+
if (!pointerActive && pointerParticle) {
332+
pointerPosition.to( center );
333+
pointerParticle.position = pointerPosition;
334+
}
335+
updateDivider();
336+
updateHeaderPosition();
337+
clearTimeout( resizeTimeoutId );
338+
resizeTimeoutId = setTimeout( () => {
339+
if (world.particleCount !== calculateParticleCount()) createWorld();
58340
}, 500 );
59341
}
60342

@@ -65,4 +347,4 @@ window.demoDescription = "In a field of points that revolves around a center, dr
65347

66348
space.bindMouse().bindTouch().play();
67349

68-
})();
350+
})();

demo/css/style.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ a:hover {
254254
padding-left: 10px;
255255
padding-right: 10px;
256256
}
257+
258+
#topmenu > a:nth-child(n + 3) { display: none; }
257259

258260
#pts { padding-left: 0; }
259261
#pts a { padding: 0; }
@@ -311,7 +313,6 @@ a:hover {
311313
}
312314

313315
#topmenu > a { padding-left: 0; }
314-
#topmenu > a:nth-child(3) { display: none; }
315316

316317
#toc { width: 50px; height: 80px; line-height: 65px; }
317318

0 commit comments

Comments
 (0)