1// Initially vibe coded with AI, but a lot of tuning was done manually
2// for it to feel natural. Some of the tuning was done through AI and some
3// was manual. This implementation is not very performant and might get
4// re-visited, but it runs mostly-fine, mostly in chromium.
5//
6// The parts that need improvement are how particles are computed. Those
7// nested loops take way too long. 2d Canvas is fine for rendering. A
8// good chance moving computation to WASM and rendering to JS would help.
9(globalThis as any).canvas_2021 = function(canvas: HTMLCanvasElement) {
10 const isStandalone = canvas.getAttribute("data-standalone") === "true";
11 // Constants for simulation
12 const PARTICLE_RADIUS = 4.5;
13 const PARTICLE_DENSITY = 0.004; // Particles per pixel
14 const MIN_SPEED = 0.05;
15 const MAX_SPEED = 6.0;
16 const FRICTION = 0.96;
17 const REPULSION_STRENGTH = 0.1;
18 const REPULSION_RADIUS = 50;
19 const FORCE_RADIUS = 400; // Increased radius
20 const FORCE_STRENGTH = 0.25;
21 const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff
22 const FORCE_SPACING = 10; // Pixels between force points
23 const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements
24 const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements
25 const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force
26 const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force
27 const OVERSCAN_PIXELS = 250;
28 const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing
29
30 let globalOpacity = 0;
31
32 if (isStandalone) {
33 canvas.style.backgroundColor = "#301D02";
34 } else {
35 canvas.style.backgroundColor = "transparent";
36 }
37
38 // Interfaces
39 interface Particle {
40 x: number;
41 y: number;
42 vx: number;
43 vy: number;
44 charge: number; // 0 to 1, affecting color
45 }
46
47 interface Force {
48 x: number;
49 y: number;
50 dx: number;
51 dy: number;
52 strength: number;
53 radius: number;
54 createdAt: number;
55 }
56
57 interface SpatialHash {
58 [key: string]: Particle[];
59 }
60
61 // State
62 let first = true;
63 let particles: Particle[] = [];
64 let forces: Force[] = [];
65 let width = canvas.width;
66 let height = canvas.height;
67 let targetParticleCount = 0;
68 let spatialHash: SpatialHash = {};
69 let ctx: CanvasRenderingContext2D | null = null;
70 let animationId: number | null = null;
71 let isRunning = false;
72
73 // Mouse tracking
74 let lastMousePosition: { x: number; y: number } | null = null;
75 // Track position of the last created force
76 let lastForcePosition: { x: number; y: number } | null = null;
77
78 // Keep track of previous canvas dimensions for resize logic
79 let previousWidth = 0;
80 let previousHeight = 0;
81
82 // Initialize and cleanup
83 function init(): void {
84 ctx = canvas.getContext("2d");
85 if (!ctx) return;
86
87 // Set canvas to full size
88 resizeCanvas();
89
90 // Event listeners
91 window.addEventListener("resize", resizeCanvas);
92 document.addEventListener("mousemove", handleMouseMove);
93
94 // Start animation immediately
95 start();
96 }
97
98 function cleanup(): void {
99 // Stop the animation
100 stop();
101
102 // Remove event listeners
103 window.removeEventListener("resize", resizeCanvas);
104 document.removeEventListener("mousemove", handleMouseMove);
105
106 // Clear arrays
107 particles = [];
108 forces = [];
109 spatialHash = {};
110 lastMousePosition = null;
111 lastForcePosition = null;
112 }
113
114 // Resize canvas and adjust particle count
115 function resizeCanvas(): void {
116 // Store previous dimensions
117 previousWidth = width;
118 previousHeight = height;
119
120 // Update to new dimensions
121 width = window.innerWidth;
122 height = window.innerHeight;
123 canvas.width = width;
124 canvas.height = height;
125
126 const oldTargetCount = targetParticleCount;
127 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
128
129 // Adjust particle count
130 if (targetParticleCount > oldTargetCount) {
131 // Add more particles if needed, but only in newly available space
132 addParticles(targetParticleCount - oldTargetCount, !first);
133 first = false;
134 }
135 // Note: Removal of excess particles happens naturally during update
136 }
137
138 // Handle mouse movement
139 function handleMouseMove(e: MouseEvent): void {
140 const rect = canvas.getBoundingClientRect();
141 const currentX = e.clientX - rect.left;
142 const currentY = e.clientY - rect.top;
143
144 // Initialize positions if this is the first movement
145 if (!lastMousePosition || !lastForcePosition) {
146 lastMousePosition = { x: currentX, y: currentY };
147 lastForcePosition = { x: currentX, y: currentY };
148 return;
149 }
150
151 // Store current mouse position
152 const mouseX = currentX;
153 const mouseY = currentY;
154
155 // Calculate vector from last mouse position to current
156 const dx = mouseX - lastMousePosition.x;
157 const dy = mouseY - lastMousePosition.y;
158 const distMoved = Math.sqrt(dx * dx + dy * dy);
159
160 // Skip if essentially no movement (avoids numerical issues)
161 if (distMoved < 0.1) {
162 return;
163 }
164
165 // Get the vector from the last force to the current mouse position
166 const forceDx = mouseX - lastForcePosition.x;
167 const forceDy = mouseY - lastForcePosition.y;
168 const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy);
169
170 // Only create forces if we've moved far enough from the last force
171 if (forceDistance >= FORCE_SPACING) {
172 // Calculate the direction vector from last force to current mouse
173 let dirX = forceDx / forceDistance;
174 let dirY = forceDy / forceDistance;
175
176 // Calculate how many force points to create
177 const numPoints = Math.floor(forceDistance / FORCE_SPACING);
178
179 // Calculate movement speed based on the recent movement
180 const movementSpeed = distMoved; // Simple approximation of speed
181
182 // Scale force strength based on movement speed
183 let speedFactor;
184 if (movementSpeed <= MIN_SPEED_THRESHOLD) {
185 speedFactor = MIN_FORCE_STRENGTH;
186 } else if (movementSpeed >= MAX_SPEED_THRESHOLD) {
187 speedFactor = MAX_FORCE_STRENGTH;
188 } else {
189 // Linear interpolation between min and max
190 const t = (movementSpeed - MIN_SPEED_THRESHOLD)
191 / (MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD);
192 speedFactor = MIN_FORCE_STRENGTH
193 + t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH);
194 }
195
196 // Store current force position to update incrementally
197 let currentForceX = lastForcePosition.x;
198 let currentForceY = lastForcePosition.y;
199
200 // Create evenly spaced force points along the path from last force to current mouse
201 for (let i = 0; i < numPoints; i++) {
202 // Calculate position for this force point
203 const t = (i + 1) / numPoints;
204 const fx = lastForcePosition.x + forceDx * t;
205 const fy = lastForcePosition.y + forceDy * t;
206
207 // Create force at this position with the direction vector
208 createForce(fx, fy, dirX, dirY, speedFactor);
209
210 // Update the last force position to this new force
211 currentForceX = fx;
212 currentForceY = fy;
213 }
214
215 // Update the last force position
216 lastForcePosition = { x: currentForceX, y: currentForceY };
217 }
218
219 // Always update the last mouse position
220 lastMousePosition = { x: mouseX, y: mouseY };
221 }
222
223 // Create a new force
224 function createForce(
225 x: number,
226 y: number,
227 dx: number,
228 dy: number,
229 strength = FORCE_STRENGTH,
230 ): void {
231 forces.push({
232 x,
233 y,
234 dx,
235 dy,
236 strength,
237 radius: 1,
238 createdAt: Date.now(),
239 });
240 }
241
242 // Improved particle addition with fill strategy options
243 function addParticles(count: number, inNewAreaOnly: boolean = false): void {
244 // Determine available space
245 const minX = -OVERSCAN_PIXELS;
246 const maxX = width + OVERSCAN_PIXELS;
247 const minY = -OVERSCAN_PIXELS;
248 const maxY = height + OVERSCAN_PIXELS;
249
250 // Use a grid system that guarantees uniform spacing of particles
251 const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius
252 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
253 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
254
255 // Track which grid cells are already occupied
256 const occupiedCells: Set<string> = new Set();
257
258 // Mark cells occupied by existing particles
259 for (const particle of particles) {
260 const cellX = Math.floor((particle.x - minX) / gridSpacing);
261 const cellY = Math.floor((particle.y - minY) / gridSpacing);
262
263 // Ensure cell coordinates are within valid range
264 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
265 occupiedCells.add(`${cellX},${cellY}`);
266 }
267 }
268
269 // Create arrays of all cells and filter by placement strategy
270 const allGridCells: { x: number; y: number }[] = [];
271
272 for (let cellY = 0; cellY < gridHeight; cellY++) {
273 for (let cellX = 0; cellX < gridWidth; cellX++) {
274 const cellKey = `${cellX},${cellY}`;
275 if (!occupiedCells.has(cellKey)) {
276 const posX = minX + (cellX + 0.5) * gridSpacing;
277 const posY = minY + (cellY + 0.5) * gridSpacing;
278
279 // For new area only placement, filter to expanded areas
280 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
281 const expandedRight = width > previousWidth;
282 const expandedBottom = height > previousHeight;
283
284 const inNewRightArea = expandedRight && posX >= previousWidth
285 && posX <= width;
286 const inNewBottomArea = expandedBottom && posY >= previousHeight
287 && posY <= height;
288
289 if (inNewRightArea || inNewBottomArea) {
290 allGridCells.push({ x: cellX, y: cellY });
291 }
292 } else if (!inNewAreaOnly) {
293 // Standard placement - add all valid cells
294 allGridCells.push({ x: cellX, y: cellY });
295 }
296 }
297 }
298 }
299
300 if (allGridCells.length == 0) {
301 throw new Error("No cells available to place particles");
302 }
303
304 // We now have all grid cells that match our placement criteria
305
306 // If we need more particles than we have available cells, we need to adjust
307 // gridSpacing to fit more cells into the same space
308 if (count > allGridCells.length) {
309 // Retry with a smaller grid spacing
310 // Proportionally reduce the grid spacing to fit the required number of particles
311 const scaleFactor = Math.sqrt(allGridCells.length / count);
312 const newGridSpacing = gridSpacing * scaleFactor;
313
314 // Clear particles and try again with new spacing
315 // This is a recursive call, but with adjusted parameters that will fit
316 return addParticlesWithCustomSpacing(
317 count,
318 inNewAreaOnly,
319 newGridSpacing,
320 );
321 }
322
323 // Shuffle the available cells for random selection
324 shuffleArray(allGridCells);
325
326 // Take the number of cells we need
327 const cellsToUse = Math.min(count, allGridCells.length);
328 const selectedCells = allGridCells.slice(0, cellsToUse);
329
330 // Create particles in selected cells
331 for (const cell of selectedCells) {
332 // Add jitter within the cell for natural look
333 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
334 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
335
336 // Calculate final position
337 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
338 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
339
340 // Create a particle at this position
341 particles.push(createParticle(x, y));
342 }
343 }
344
345 // Helper function to add particles with custom grid spacing
346 function addParticlesWithCustomSpacing(
347 count: number,
348 inNewAreaOnly: boolean,
349 gridSpacing: number,
350 ): void {
351 if (gridSpacing == 0) throw new Error("Grid spacing is 0");
352 // Determine available space
353 const minX = -OVERSCAN_PIXELS;
354 const maxX = width + OVERSCAN_PIXELS;
355 const minY = -OVERSCAN_PIXELS;
356 const maxY = height + OVERSCAN_PIXELS;
357
358 // Create grid using the custom spacing
359 const gridWidth = Math.ceil((maxX - minX) / gridSpacing);
360 const gridHeight = Math.ceil((maxY - minY) / gridSpacing);
361
362 // Track which grid cells are already occupied
363 const occupiedCells: Set<string> = new Set();
364
365 // Mark cells occupied by existing particles
366 for (const particle of particles) {
367 const cellX = Math.floor((particle.x - minX) / gridSpacing);
368 const cellY = Math.floor((particle.y - minY) / gridSpacing);
369
370 // Ensure cell coordinates are within valid range
371 if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) {
372 occupiedCells.add(`${cellX},${cellY}`);
373 }
374 }
375
376 // Create arrays of all cells and filter by placement strategy
377 const allGridCells: { x: number; y: number }[] = [];
378
379 for (let cellY = 0; cellY < gridHeight; cellY++) {
380 for (let cellX = 0; cellX < gridWidth; cellX++) {
381 const cellKey = `${cellX},${cellY}`;
382 if (!occupiedCells.has(cellKey)) {
383 const posX = minX + (cellX + 0.5) * gridSpacing;
384 const posY = minY + (cellY + 0.5) * gridSpacing;
385
386 // For new area only placement, filter to expanded areas
387 if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) {
388 const expandedRight = width > previousWidth;
389 const expandedBottom = height > previousHeight;
390
391 const inNewRightArea = expandedRight && posX >= previousWidth
392 && posX <= width;
393 const inNewBottomArea = expandedBottom && posY >= previousHeight
394 && posY <= height;
395
396 if (inNewRightArea || inNewBottomArea) {
397 allGridCells.push({ x: cellX, y: cellY });
398 }
399 } else if (!inNewAreaOnly) {
400 // Standard placement - add all valid cells
401 allGridCells.push({ x: cellX, y: cellY });
402 }
403 }
404 }
405 }
406
407 // Shuffle the available cells for random distribution
408 shuffleArray(allGridCells);
409
410 // Take the number of cells we need (or all if we have fewer)
411 const cellsToUse = Math.min(count, allGridCells.length);
412
413 // Create particles in selected cells
414 for (let i = 0; i < cellsToUse; i++) {
415 const cell = allGridCells[i];
416
417 // Add jitter within the cell
418 const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8;
419 const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8;
420
421 // Calculate final position
422 const x = minX + (cell.x + 0.5) * gridSpacing + jitterX;
423 const y = minY + (cell.y + 0.5) * gridSpacing + jitterY;
424
425 // Create a particle at this position
426 particles.push(createParticle(x, y));
427 }
428 }
429
430 // Utility to shuffle an array (Fisher-Yates algorithm)
431 function shuffleArray<T>(array: T[]): void {
432 for (let i = array.length - 1; i > 0; i--) {
433 const j = Math.floor(Math.random() * (i + 1));
434 [array[i], array[j]] = [array[j], array[i]];
435 }
436 }
437
438 // Simplified createParticle function that just places at a specific position
439 function createParticle(x: number, y: number): Particle {
440 return {
441 x: x + (Math.random() * 4 - 2),
442 y: y + (Math.random() * 4 - 2),
443 vx: 0,
444 vy: 0,
445 charge: 0,
446 };
447 }
448
449 // Function to create a particle on one of the edges
450 function createParticleOnEdge(): Particle {
451 // Overscan bounds with fixed pixel size
452 const minX = -OVERSCAN_PIXELS;
453 const maxX = width + OVERSCAN_PIXELS;
454 const minY = -OVERSCAN_PIXELS;
455 const maxY = height + OVERSCAN_PIXELS;
456
457 let x: number, y: number;
458
459 // Place on one of the edges
460 const edge = Math.floor(Math.random() * 4);
461 switch (edge) {
462 case 0: // Top
463 x = minX + Math.random() * (maxX - minX);
464 y = minY;
465 break;
466 case 1: // Right
467 x = maxX;
468 y = minY + Math.random() * (maxY - minY);
469 break;
470 case 2: // Bottom
471 x = minX + Math.random() * (maxX - minX);
472 y = maxY;
473 break;
474 case 3: // Left
475 x = minX;
476 y = minY + Math.random() * (maxY - minY);
477 break;
478 default:
479 x = minX + Math.random() * (maxX - minX);
480 y = minY + Math.random() * (maxY - minY);
481 }
482
483 return createParticle(x, y);
484 }
485
486 // Spatial hashing functions
487 function getHashKey(x: number, y: number): string {
488 const cellX = Math.floor(x / CELL_SIZE);
489 const cellY = Math.floor(y / CELL_SIZE);
490 return `${cellX},${cellY}`;
491 }
492
493 function addToSpatialHash(particle: Particle): void {
494 const key = getHashKey(particle.x, particle.y);
495 if (!spatialHash[key]) {
496 spatialHash[key] = [];
497 }
498 spatialHash[key].push(particle);
499 }
500
501 function updateSpatialHash(): void {
502 // Clear previous hash
503 spatialHash = {};
504
505 // Add all particles to hash
506 for (const particle of particles) {
507 addToSpatialHash(particle);
508 }
509 }
510
511 function getNearbyParticles(
512 x: number,
513 y: number,
514 radius: number,
515 ): Particle[] {
516 const result: Particle[] = [];
517 const cellRadius = Math.ceil(radius / CELL_SIZE);
518
519 const centerCellX = Math.floor(x / CELL_SIZE);
520 const centerCellY = Math.floor(y / CELL_SIZE);
521
522 for (
523 let cellX = centerCellX - cellRadius;
524 cellX <= centerCellX + cellRadius;
525 cellX++
526 ) {
527 for (
528 let cellY = centerCellY - cellRadius;
529 cellY <= centerCellY + cellRadius;
530 cellY++
531 ) {
532 const key = `${cellX},${cellY}`;
533 const cell = spatialHash[key];
534
535 if (cell) {
536 result.push(...cell);
537 }
538 }
539 }
540
541 return result;
542 }
543
544 // Main update function
545 function update(): void {
546 const now = Date.now();
547 // Fixed pixel overscan
548 const minX = -OVERSCAN_PIXELS;
549 const maxX = width + OVERSCAN_PIXELS;
550 const minY = -OVERSCAN_PIXELS;
551 const maxY = height + OVERSCAN_PIXELS;
552
553 // Update spatial hash
554 updateSpatialHash();
555
556 // Update forces and remove expired ones
557 if (forces.length > 40) {
558 forces = forces.slice(-40);
559 }
560 forces = forces.filter((force) => {
561 force.strength *= 0.95;
562 force.radius *= 0.95;
563 return force.strength > 0.001;
564 });
565
566 // Update particles
567 const newParticles: Particle[] = [];
568
569 for (const particle of particles) {
570 // Apply forces
571 for (const force of forces) {
572 const dx = particle.x - force.x;
573 const dy = particle.y - force.y;
574 const distSq = dx * dx + dy * dy;
575
576 const radius = force.radius * FORCE_RADIUS;
577
578 if (distSq < radius * radius) {
579 const dist = Math.sqrt(distSq);
580
581 // Exponential falloff - much more concentrated at center
582 // (1 - x/R)^n where n controls how sharp the falloff is
583 const normalizedDist = dist / radius;
584 const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
585
586 // Calculate force line projection for directional effect
587 // This makes particles along the force's path experience stronger effect
588 const dotProduct = (dx * -force.dx) + (dy * -force.dy);
589 const projectionFactor = Math.max(0, dotProduct / dist);
590
591 // Apply the combined factors - stronger directional bias
592 const finalFactor = factor * force.strength
593 * (0.1 + 0.9 * projectionFactor);
594
595 particle.vx += force.dx * finalFactor;
596 particle.vy += force.dy * finalFactor;
597 // charge for the first 100ms
598 if ((now - force.createdAt) < 100) {
599 particle.charge = Math.min(
600 1,
601 particle.charge + (finalFactor * finalFactor) * 0.2,
602 );
603 }
604 }
605 }
606
607 // Apply repulsion from nearby particles
608 const nearby = getNearbyParticles(
609 particle.x,
610 particle.y,
611 REPULSION_RADIUS,
612 );
613
614 for (const other of nearby) {
615 if (other === particle) continue;
616
617 const dx = particle.x - other.x;
618 const dy = particle.y - other.y;
619 const distSq = dx * dx + dy * dy;
620
621 if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) {
622 const dist = Math.sqrt(distSq);
623 const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS);
624
625 const fx = dx / dist * factor;
626 const fy = dy / dist * factor;
627
628 particle.vx += fx;
629 particle.vy += fy;
630 }
631 }
632
633 // Apply friction
634 particle.vx *= FRICTION;
635 particle.vy *= FRICTION;
636
637 // Ensure minimum speed
638 const speed = Math.sqrt(
639 particle.vx * particle.vx + particle.vy * particle.vy,
640 );
641 if (speed < MIN_SPEED && speed > 0) {
642 const scale = MIN_SPEED / speed;
643 particle.vx *= scale;
644 particle.vy *= scale;
645 }
646
647 // Cap at maximum speed
648 if (speed > MAX_SPEED) {
649 const scale = MAX_SPEED / speed;
650 particle.vx *= scale;
651 particle.vy *= scale;
652 }
653
654 // Update position
655 particle.x += particle.vx;
656 particle.y += particle.vy;
657
658 // Decrease charge
659 particle.charge *= 0.99;
660
661 // Check if particle is within extended bounds
662 if (
663 particle.x >= minX && particle.x <= maxX
664 && particle.y >= minY && particle.y <= maxY
665 ) {
666 // If outside screen but within overscan, keep it if we need more particles
667 if (
668 (particle.x < 0 || particle.x > width
669 || particle.y < 0 || particle.y > height)
670 && newParticles.length >= targetParticleCount
671 ) {
672 continue;
673 }
674
675 newParticles.push(particle);
676 } else {
677 // Out of bounds, respawn if needed
678 if (newParticles.length < targetParticleCount) {
679 newParticles.push(createParticleOnEdge());
680 }
681 }
682 }
683
684 // Add more particles if needed
685 while (newParticles.length < targetParticleCount) {
686 newParticles.push(createParticleOnEdge());
687 }
688
689 particles = newParticles;
690 }
691
692 // Render function
693 const mul = isStandalone ? 0.9 : 0.5;
694 const add = isStandalone ? 0.1 : 0.03;
695 function render(): void {
696 if (!ctx) return;
697
698 // Clear canvas
699 ctx.clearRect(0, 0, width, height);
700
701 // Draw particles
702 for (const particle of particles) {
703 // Only draw if within canvas bounds (plus a small margin)
704 if (
705 particle.x >= -PARTICLE_RADIUS
706 && particle.x <= width + PARTICLE_RADIUS
707 && particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS
708 ) {
709 ctx.beginPath();
710 ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2);
711
712 // Color based on charge
713 ctx.fillStyle = "#FFCB1F";
714 ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity;
715 ctx.fill();
716 }
717 }
718
719 // // Debug: Draw forces and falloff visualization
720 // if (ctx) {
721 // for (const force of forces) {
722 // const R = force.radius * FORCE_RADIUS;
723
724 // // Draw force point
725 // ctx.beginPath();
726 // ctx.arc(force.x, force.y, 5, 0, Math.PI * 2);
727 // ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
728 // ctx.fill();
729
730 // // Draw force direction
731 // ctx.beginPath();
732 // ctx.moveTo(force.x, force.y);
733 // ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20);
734 // ctx.strokeStyle = 'red';
735 // ctx.stroke();
736
737 // // Visualize the falloff curve with rings
738 // for (let i = 0; i <= 10; i++) {
739 // const radius = (R * i) / 10;
740 // const normalizedDist = radius / R;
741 // const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT);
742
743 // ctx.beginPath();
744 // ctx.arc(force.x, force.y, radius, 0, Math.PI * 2);
745 // ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`;
746 // ctx.stroke();
747 // }
748 // }
749 // }
750 }
751
752 // Animation loop
753 let r = Math.random();
754 function animate(): void {
755 globalOpacity = Math.min(1, globalOpacity + 0.03);
756 update();
757 render();
758
759 if (isRunning) {
760 animationId = requestAnimationFrame(animate);
761 }
762 }
763
764 // Start/stop functions
765 function start(): void {
766 if (isRunning) return;
767
768 // Calculate target particle count based on canvas size
769 targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY);
770
771 // Clear any existing particles and create new ones with proper spacing
772 particles = [];
773 addParticles(targetParticleCount);
774
775 isRunning = true;
776 animate();
777 }
778
779 function stop(): void {
780 isRunning = false;
781
782 if (animationId !== null) {
783 cancelAnimationFrame(animationId);
784 animationId = null;
785 }
786 }
787
788 init();
789 return cleanup;
790};