// Vibe coded. (globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) { const isStandalone = canvas.getAttribute("data-standalone") === "true"; // Constants for simulation const PARTICLE_RADIUS = 4.5; const PARTICLE_DENSITY = 0.004; // Particles per pixel const MIN_SPEED = 0.05; const MAX_SPEED = 6.0; const FRICTION = 0.96; const REPULSION_STRENGTH = 0.1; const REPULSION_RADIUS = 50; const FORCE_RADIUS = 400; // Increased radius const FORCE_STRENGTH = 0.25; const FORCE_FALLOFF_EXPONENT = 3; // Higher value = sharper falloff const FORCE_SPACING = 10; // Pixels between force points const MIN_FORCE_STRENGTH = 0.05; // Minimum force strength for very slow movements const MAX_FORCE_STRENGTH = 0.4; // Maximum force strength for fast movements const MIN_SPEED_THRESHOLD = 1; // Movement speed (px/frame) that produces minimum force const MAX_SPEED_THRESHOLD = 20; // Movement speed that produces maximum force const OVERSCAN_PIXELS = 250; const CELL_SIZE = REPULSION_RADIUS; // For spatial hashing let globalOpacity = 0; if (isStandalone) { canvas.style.backgroundColor = "#301D02"; } else { canvas.style.backgroundColor = "transparent"; } // Interfaces interface Particle { x: number; y: number; vx: number; vy: number; charge: number; // 0 to 1, affecting color } interface Force { x: number; y: number; dx: number; dy: number; strength: number; radius: number; createdAt: number; } interface SpatialHash { [key: string]: Particle[]; } // State let first = true; let particles: Particle[] = []; let forces: Force[] = []; let width = canvas.width; let height = canvas.height; let targetParticleCount = 0; let spatialHash: SpatialHash = {}; let ctx: CanvasRenderingContext2D | null = null; let animationId: number | null = null; let isRunning = false; // Mouse tracking let lastMousePosition: { x: number; y: number } | null = null; // Track position of the last created force let lastForcePosition: { x: number; y: number } | null = null; // Keep track of previous canvas dimensions for resize logic let previousWidth = 0; let previousHeight = 0; // Initialize and cleanup function init(): void { ctx = canvas.getContext("2d"); if (!ctx) return; // Set canvas to full size resizeCanvas(); // Event listeners globalThis.addEventListener("resize", resizeCanvas); document.addEventListener("mousemove", handleMouseMove); // Start animation immediately start(); } function cleanup(): void { // Stop the animation stop(); // Remove event listeners globalThis.removeEventListener("resize", resizeCanvas); document.removeEventListener("mousemove", handleMouseMove); // Clear arrays particles = []; forces = []; spatialHash = {}; lastMousePosition = null; lastForcePosition = null; } // Resize canvas and adjust particle count function resizeCanvas(): void { // Store previous dimensions previousWidth = width; previousHeight = height; // Update to new dimensions width = globalThis.innerWidth; height = globalThis.innerHeight; canvas.width = width; canvas.height = height; const oldTargetCount = targetParticleCount; targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY); // Adjust particle count if (targetParticleCount > oldTargetCount) { // Add more particles if needed, but only in newly available space addParticles(targetParticleCount - oldTargetCount, !first); first = false; } // Note: Removal of excess particles happens naturally during update } // Handle mouse movement function handleMouseMove(e: MouseEvent): void { const rect = canvas.getBoundingClientRect(); const currentX = e.clientX - rect.left; const currentY = e.clientY - rect.top; // Initialize positions if this is the first movement if (!lastMousePosition || !lastForcePosition) { lastMousePosition = { x: currentX, y: currentY }; lastForcePosition = { x: currentX, y: currentY }; return; } // Store current mouse position const mouseX = currentX; const mouseY = currentY; // Calculate vector from last mouse position to current const dx = mouseX - lastMousePosition.x; const dy = mouseY - lastMousePosition.y; const distMoved = Math.sqrt(dx * dx + dy * dy); // Skip if essentially no movement (avoids numerical issues) if (distMoved < 0.1) { return; } // Get the vector from the last force to the current mouse position const forceDx = mouseX - lastForcePosition.x; const forceDy = mouseY - lastForcePosition.y; const forceDistance = Math.sqrt(forceDx * forceDx + forceDy * forceDy); // Only create forces if we've moved far enough from the last force if (forceDistance >= FORCE_SPACING) { // Calculate the direction vector from last force to current mouse let dirX = forceDx / forceDistance; let dirY = forceDy / forceDistance; // Calculate how many force points to create const numPoints = Math.floor(forceDistance / FORCE_SPACING); // Calculate movement speed based on the recent movement const movementSpeed = distMoved; // Simple approximation of speed // Scale force strength based on movement speed let speedFactor; if (movementSpeed <= MIN_SPEED_THRESHOLD) { speedFactor = MIN_FORCE_STRENGTH; } else if (movementSpeed >= MAX_SPEED_THRESHOLD) { speedFactor = MAX_FORCE_STRENGTH; } else { // Linear interpolation between min and max const t = (movementSpeed - MIN_SPEED_THRESHOLD) / (MAX_SPEED_THRESHOLD - MIN_SPEED_THRESHOLD); speedFactor = MIN_FORCE_STRENGTH + t * (MAX_FORCE_STRENGTH - MIN_FORCE_STRENGTH); } // Store current force position to update incrementally let currentForceX = lastForcePosition.x; let currentForceY = lastForcePosition.y; // Create evenly spaced force points along the path from last force to current mouse for (let i = 0; i < numPoints; i++) { // Calculate position for this force point const t = (i + 1) / numPoints; const fx = lastForcePosition.x + forceDx * t; const fy = lastForcePosition.y + forceDy * t; // Create force at this position with the direction vector createForce(fx, fy, dirX, dirY, speedFactor); // Update the last force position to this new force currentForceX = fx; currentForceY = fy; } // Update the last force position lastForcePosition = { x: currentForceX, y: currentForceY }; } // Always update the last mouse position lastMousePosition = { x: mouseX, y: mouseY }; } // Create a new force function createForce( x: number, y: number, dx: number, dy: number, strength = FORCE_STRENGTH, ): void { forces.push({ x, y, dx, dy, strength, radius: 1, createdAt: Date.now(), }); } // Improved particle addition with fill strategy options function addParticles(count: number, inNewAreaOnly: boolean = false): void { // Determine available space const minX = -OVERSCAN_PIXELS; const maxX = width + OVERSCAN_PIXELS; const minY = -OVERSCAN_PIXELS; const maxY = height + OVERSCAN_PIXELS; // Use a grid system that guarantees uniform spacing of particles const gridSpacing = REPULSION_RADIUS * 0.8; // Slightly less than repulsion radius const gridWidth = Math.ceil((maxX - minX) / gridSpacing); const gridHeight = Math.ceil((maxY - minY) / gridSpacing); // Track which grid cells are already occupied const occupiedCells: Set = new Set(); // Mark cells occupied by existing particles for (const particle of particles) { const cellX = Math.floor((particle.x - minX) / gridSpacing); const cellY = Math.floor((particle.y - minY) / gridSpacing); // Ensure cell coordinates are within valid range if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) { occupiedCells.add(`${cellX},${cellY}`); } } // Create arrays of all cells and filter by placement strategy const allGridCells: { x: number; y: number }[] = []; for (let cellY = 0; cellY < gridHeight; cellY++) { for (let cellX = 0; cellX < gridWidth; cellX++) { const cellKey = `${cellX},${cellY}`; if (!occupiedCells.has(cellKey)) { const posX = minX + (cellX + 0.5) * gridSpacing; const posY = minY + (cellY + 0.5) * gridSpacing; // For new area only placement, filter to expanded areas if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) { const expandedRight = width > previousWidth; const expandedBottom = height > previousHeight; const inNewRightArea = expandedRight && posX >= previousWidth && posX <= width; const inNewBottomArea = expandedBottom && posY >= previousHeight && posY <= height; if (inNewRightArea || inNewBottomArea) { allGridCells.push({ x: cellX, y: cellY }); } } else if (!inNewAreaOnly) { // Standard placement - add all valid cells allGridCells.push({ x: cellX, y: cellY }); } } } } if (allGridCells.length == 0) { throw new Error("No cells available to place particles"); } // We now have all grid cells that match our placement criteria // If we need more particles than we have available cells, we need to adjust // gridSpacing to fit more cells into the same space if (count > allGridCells.length) { // Retry with a smaller grid spacing // Proportionally reduce the grid spacing to fit the required number of particles const scaleFactor = Math.sqrt(allGridCells.length / count); const newGridSpacing = gridSpacing * scaleFactor; // Clear particles and try again with new spacing // This is a recursive call, but with adjusted parameters that will fit return addParticlesWithCustomSpacing( count, inNewAreaOnly, newGridSpacing, ); } // Shuffle the available cells for random selection shuffleArray(allGridCells); // Take the number of cells we need const cellsToUse = Math.min(count, allGridCells.length); const selectedCells = allGridCells.slice(0, cellsToUse); // Create particles in selected cells for (const cell of selectedCells) { // Add jitter within the cell for natural look const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8; const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8; // Calculate final position const x = minX + (cell.x + 0.5) * gridSpacing + jitterX; const y = minY + (cell.y + 0.5) * gridSpacing + jitterY; // Create a particle at this position particles.push(createParticle(x, y)); } } // Helper function to add particles with custom grid spacing function addParticlesWithCustomSpacing( count: number, inNewAreaOnly: boolean, gridSpacing: number, ): void { if (gridSpacing == 0) throw new Error("Grid spacing is 0"); // Determine available space const minX = -OVERSCAN_PIXELS; const maxX = width + OVERSCAN_PIXELS; const minY = -OVERSCAN_PIXELS; const maxY = height + OVERSCAN_PIXELS; // Create grid using the custom spacing const gridWidth = Math.ceil((maxX - minX) / gridSpacing); const gridHeight = Math.ceil((maxY - minY) / gridSpacing); // Track which grid cells are already occupied const occupiedCells: Set = new Set(); // Mark cells occupied by existing particles for (const particle of particles) { const cellX = Math.floor((particle.x - minX) / gridSpacing); const cellY = Math.floor((particle.y - minY) / gridSpacing); // Ensure cell coordinates are within valid range if (cellX >= 0 && cellX < gridWidth && cellY >= 0 && cellY < gridHeight) { occupiedCells.add(`${cellX},${cellY}`); } } // Create arrays of all cells and filter by placement strategy const allGridCells: { x: number; y: number }[] = []; for (let cellY = 0; cellY < gridHeight; cellY++) { for (let cellX = 0; cellX < gridWidth; cellX++) { const cellKey = `${cellX},${cellY}`; if (!occupiedCells.has(cellKey)) { const posX = minX + (cellX + 0.5) * gridSpacing; const posY = minY + (cellY + 0.5) * gridSpacing; // For new area only placement, filter to expanded areas if (inNewAreaOnly && previousWidth > 0 && previousHeight > 0) { const expandedRight = width > previousWidth; const expandedBottom = height > previousHeight; const inNewRightArea = expandedRight && posX >= previousWidth && posX <= width; const inNewBottomArea = expandedBottom && posY >= previousHeight && posY <= height; if (inNewRightArea || inNewBottomArea) { allGridCells.push({ x: cellX, y: cellY }); } } else if (!inNewAreaOnly) { // Standard placement - add all valid cells allGridCells.push({ x: cellX, y: cellY }); } } } } // Shuffle the available cells for random distribution shuffleArray(allGridCells); // Take the number of cells we need (or all if we have fewer) const cellsToUse = Math.min(count, allGridCells.length); // Create particles in selected cells for (let i = 0; i < cellsToUse; i++) { const cell = allGridCells[i]; // Add jitter within the cell const jitterX = (Math.random() - 0.5) * gridSpacing * 0.8; const jitterY = (Math.random() - 0.5) * gridSpacing * 0.8; // Calculate final position const x = minX + (cell.x + 0.5) * gridSpacing + jitterX; const y = minY + (cell.y + 0.5) * gridSpacing + jitterY; // Create a particle at this position particles.push(createParticle(x, y)); } } // Utility to shuffle an array (Fisher-Yates algorithm) function shuffleArray(array: T[]): void { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } } // Simplified createParticle function that just places at a specific position function createParticle(x: number, y: number): Particle { return { x: x + (Math.random() * 4 - 2), y: y + (Math.random() * 4 - 2), vx: 0, vy: 0, charge: 0, }; } // Function to create a particle on one of the edges function createParticleOnEdge(): Particle { // Overscan bounds with fixed pixel size const minX = -OVERSCAN_PIXELS; const maxX = width + OVERSCAN_PIXELS; const minY = -OVERSCAN_PIXELS; const maxY = height + OVERSCAN_PIXELS; let x: number, y: number; // Place on one of the edges const edge = Math.floor(Math.random() * 4); switch (edge) { case 0: // Top x = minX + Math.random() * (maxX - minX); y = minY; break; case 1: // Right x = maxX; y = minY + Math.random() * (maxY - minY); break; case 2: // Bottom x = minX + Math.random() * (maxX - minX); y = maxY; break; case 3: // Left x = minX; y = minY + Math.random() * (maxY - minY); break; default: x = minX + Math.random() * (maxX - minX); y = minY + Math.random() * (maxY - minY); } return createParticle(x, y); } // Spatial hashing functions function getHashKey(x: number, y: number): string { const cellX = Math.floor(x / CELL_SIZE); const cellY = Math.floor(y / CELL_SIZE); return `${cellX},${cellY}`; } function addToSpatialHash(particle: Particle): void { const key = getHashKey(particle.x, particle.y); if (!spatialHash[key]) { spatialHash[key] = []; } spatialHash[key].push(particle); } function updateSpatialHash(): void { // Clear previous hash spatialHash = {}; // Add all particles to hash for (const particle of particles) { addToSpatialHash(particle); } } function getNearbyParticles( x: number, y: number, radius: number, ): Particle[] { const result: Particle[] = []; const cellRadius = Math.ceil(radius / CELL_SIZE); const centerCellX = Math.floor(x / CELL_SIZE); const centerCellY = Math.floor(y / CELL_SIZE); for ( let cellX = centerCellX - cellRadius; cellX <= centerCellX + cellRadius; cellX++ ) { for ( let cellY = centerCellY - cellRadius; cellY <= centerCellY + cellRadius; cellY++ ) { const key = `${cellX},${cellY}`; const cell = spatialHash[key]; if (cell) { result.push(...cell); } } } return result; } // Main update function function update(): void { const now = Date.now(); // Fixed pixel overscan const minX = -OVERSCAN_PIXELS; const maxX = width + OVERSCAN_PIXELS; const minY = -OVERSCAN_PIXELS; const maxY = height + OVERSCAN_PIXELS; // Update spatial hash updateSpatialHash(); // Update forces and remove expired ones if (forces.length > 40) { forces = forces.slice(-40); } forces = forces.filter((force) => { force.strength *= 0.95; force.radius *= 0.95; return force.strength > 0.001; }); // Update particles const newParticles: Particle[] = []; for (const particle of particles) { // Apply forces for (const force of forces) { const dx = particle.x - force.x; const dy = particle.y - force.y; const distSq = dx * dx + dy * dy; const radius = force.radius * FORCE_RADIUS; if (distSq < radius * radius) { const dist = Math.sqrt(distSq); // Exponential falloff - much more concentrated at center // (1 - x/R)^n where n controls how sharp the falloff is const normalizedDist = dist / radius; const factor = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT); // Calculate force line projection for directional effect // This makes particles along the force's path experience stronger effect const dotProduct = (dx * -force.dx) + (dy * -force.dy); const projectionFactor = Math.max(0, dotProduct / dist); // Apply the combined factors - stronger directional bias const finalFactor = factor * force.strength * (0.1 + 0.9 * projectionFactor); particle.vx += force.dx * finalFactor; particle.vy += force.dy * finalFactor; // charge for the first 100ms if ((now - force.createdAt) < 100) { particle.charge = Math.min( 1, particle.charge + (finalFactor * finalFactor) * 0.2, ); } } } // Apply repulsion from nearby particles const nearby = getNearbyParticles( particle.x, particle.y, REPULSION_RADIUS, ); for (const other of nearby) { if (other === particle) continue; const dx = particle.x - other.x; const dy = particle.y - other.y; const distSq = dx * dx + dy * dy; if (distSq < REPULSION_RADIUS * REPULSION_RADIUS && distSq > 0) { const dist = Math.sqrt(distSq); const factor = REPULSION_STRENGTH * (1 - dist / REPULSION_RADIUS); const fx = dx / dist * factor; const fy = dy / dist * factor; particle.vx += fx; particle.vy += fy; } } // Apply friction particle.vx *= FRICTION; particle.vy *= FRICTION; // Ensure minimum speed const speed = Math.sqrt( particle.vx * particle.vx + particle.vy * particle.vy, ); if (speed < MIN_SPEED && speed > 0) { const scale = MIN_SPEED / speed; particle.vx *= scale; particle.vy *= scale; } // Cap at maximum speed if (speed > MAX_SPEED) { const scale = MAX_SPEED / speed; particle.vx *= scale; particle.vy *= scale; } // Update position particle.x += particle.vx; particle.y += particle.vy; // Decrease charge particle.charge *= 0.99; // Check if particle is within extended bounds if ( particle.x >= minX && particle.x <= maxX && particle.y >= minY && particle.y <= maxY ) { // If outside screen but within overscan, keep it if we need more particles if ( (particle.x < 0 || particle.x > width || particle.y < 0 || particle.y > height) && newParticles.length >= targetParticleCount ) { continue; } newParticles.push(particle); } else { // Out of bounds, respawn if needed if (newParticles.length < targetParticleCount) { newParticles.push(createParticleOnEdge()); } } } // Add more particles if needed while (newParticles.length < targetParticleCount) { newParticles.push(createParticleOnEdge()); } particles = newParticles; } // Render function const mul = isStandalone ? 0.9 : 0.5; const add = isStandalone ? 0.1 : 0.03; function render(): void { if (!ctx) return; // Clear canvas ctx.clearRect(0, 0, width, height); // Draw particles for (const particle of particles) { // Only draw if within canvas bounds (plus a small margin) if ( particle.x >= -PARTICLE_RADIUS && particle.x <= width + PARTICLE_RADIUS && particle.y >= -PARTICLE_RADIUS && particle.y <= height + PARTICLE_RADIUS ) { ctx.beginPath(); ctx.arc(particle.x, particle.y, PARTICLE_RADIUS, 0, Math.PI * 2); // Color based on charge ctx.fillStyle = "#FFCB1F"; ctx.globalAlpha = (particle.charge * mul + add) * globalOpacity; ctx.fill(); } } // // Debug: Draw forces and falloff visualization // if (ctx) { // for (const force of forces) { // const R = force.radius * FORCE_RADIUS; // // Draw force point // ctx.beginPath(); // ctx.arc(force.x, force.y, 5, 0, Math.PI * 2); // ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // ctx.fill(); // // Draw force direction // ctx.beginPath(); // ctx.moveTo(force.x, force.y); // ctx.lineTo(force.x + force.dx * 20, force.y + force.dy * 20); // ctx.strokeStyle = 'red'; // ctx.stroke(); // // Visualize the falloff curve with rings // for (let i = 0; i <= 10; i++) { // const radius = (R * i) / 10; // const normalizedDist = radius / R; // const intensity = Math.pow(1 - normalizedDist, FORCE_FALLOFF_EXPONENT); // ctx.beginPath(); // ctx.arc(force.x, force.y, radius, 0, Math.PI * 2); // ctx.strokeStyle = `rgba(255, 0, 0, ${intensity * 0.2})`; // ctx.stroke(); // } // } // } } // Animation loop let r = Math.random(); function animate(): void { globalOpacity = Math.min(1, globalOpacity + 0.03); update(); render(); if (isRunning) { animationId = requestAnimationFrame(animate); } } // Start/stop functions function start(): void { if (isRunning) return; // Calculate target particle count based on canvas size targetParticleCount = Math.floor(width * height * PARTICLE_DENSITY); // Clear any existing particles and create new ones with proper spacing particles = []; addParticles(targetParticleCount); isRunning = true; animate(); } function stop(): void { isRunning = false; if (animationId !== null) { cancelAnimationFrame(animationId); animationId = null; } } init(); return cleanup; };