it was weird. i pressed delete on a subfolder, i think one of the pages.off folders that i was using. and then, suddenly, nvim on windows 7 decided to delete every file in the directory. they weren't shred off the space time continuum, but just marked deleted. i had to pay $80 to get access to a software that could see them. bleh! just seeing all my work, a little over a week, was pretty heart shattering. but i remembered that long ago, a close friend said i could call them whenever i was feeling sad. i finally took them up on that offer. the first time i've ever called someone for emotional support. but it's ok. i got it back. and the site framework is better than ever. i'm gonna commit and push more often. the repo is private anyways.
783 lines
24 KiB
TypeScript
783 lines
24 KiB
TypeScript
// 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<string> = 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<string> = 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<T>(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;
|
|
};
|