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+ } ) ( ) ;
0 commit comments