sitegen/src/file-viewer/scripts/canvas_2021.client.ts
clover caruso 310170fc98 i accidentally deleted the repo, but recovered it. i'll start committing
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.
2025-06-06 23:38:02 -07:00

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;
};