Skip to content

Instantly share code, notes, and snippets.

@bosley
Created December 11, 2024 07:05
Show Gist options
  • Select an option

  • Save bosley/c79b6aca6fc9c6146b67ad0ac4a78fdb to your computer and use it in GitHub Desktop.

Select an option

Save bosley/c79b6aca6fc9c6146b67ad0ac4a78fdb to your computer and use it in GitHub Desktop.
chaos-particls-color-spread.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Particle Zen</title>
<style>
/* Ma - embracing meaningful void */
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
color: rgba(255, 255, 255, 0.85);
font-family: "Space Mono", monospace;
overflow: hidden;
}
/* Kanso - elimination of clutter */
#particle-canvas {
position: fixed;
top: 0;
left: 0;
z-index: 1;
}
/* Seijaku - tranquil control panel */
.control-panel {
position: fixed;
right: 2rem;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
padding: 2rem;
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 2;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 200px;
}
/* Yugen - mysterious grace in controls */
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.8rem;
opacity: 0.7;
transition: opacity 0.3s;
}
label:hover {
opacity: 1;
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.2);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 8px;
height: 8px;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
cursor: pointer;
transition: all 0.3s;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: rgba(255, 255, 255, 1);
}
</style>
</head>
<body>
<canvas id="particle-canvas"></canvas>
<div class="control-panel">
<div class="control-group">
<label for="particle-count">Particle Density</label>
<input type="range" id="particle-count" min="100" max="2000" value="800">
</div>
<div class="control-group">
<label for="particle-speed">Flow Speed</label>
<input type="range" id="particle-speed" min="0.1" max="5" step="0.1" value="1">
</div>
<div class="control-group">
<label for="connection-distance">Connection Range</label>
<input type="range" id="connection-distance" min="50" max="200" value="100">
</div>
<div class="control-group">
<label for="particle-size">Particle Size</label>
<input type="range" id="particle-size" min="1" max="5" step="0.5" value="2">
</div>
<div class="control-group">
<label for="max-connections">Max Connections per Particle</label>
<input type="range" id="max-connections" min="1" max="10" value="3">
</div>
<div class="control-group">
<label for="max-spread">Max Color Spread</label>
<input type="range" id="max-spread" min="5" max="30" value="15">
</div>
<div class="control-group">
<label for="color-lifetime">Color Lifetime</label>
<input type="range" id="color-lifetime" min="1" max="10" step="0.5" value="3">
</div>
<div class="control-group">
<label for="spread-speed">Spread Aggressiveness</label>
<input type="range" id="spread-speed" min="0.01" max="0.2" step="0.01" value="0.05">
</div>
<div class="control-group">
<label for="colony-size">Colony Survival Size</label>
<input type="range" id="colony-size" min="2" max="8" step="1" value="3">
</div>
<div class="control-group">
<label for="join-speed">Join Speed Threshold</label>
<input type="range" id="join-speed" min="0.1" max="1" step="0.05" value="0.2">
</div>
</div>
<script>
// Particle System with Zen-inspired implementation
class ParticleSystem {
#particles = [];
#ctx = null;
#canvas = null;
#settings = {
count: 800,
speed: 1,
connectionDistance: 100,
size: 2,
colorPropagationSpeed: 0.05,
colorFadeRate: 0.01,
clickEffectRadius: 50,
maxConnectionsPerParticle: 3,
maxColorSpreadCount: 15,
colorLifetime: 3,
spreadSpeed: 0.05,
colonySize: 3,
springStrength: 0.03, // Spring force strength
springDamping: 0.7, // Damping factor for spring oscillations
springLength: 30, // Desired distance between connected particles
collisionRadius: 15, // Radius for collision detection with bundles
bundleThreshold: 4, // Minimum connections to form a bundle
bundlePullStrength: 0.03, // Increased from 0.01 for faster movement
bundleBounceDamping: 0.9, // Energy retention on wall bounce
clusterLifetime: 10, // How long a cluster lives after formation (seconds)
emissionChance: 0.25, // Chance to emit on death
emissionRadius: 50, // Size of emission effect
rippleLifetime: 0.8, // Ripple animation duration in seconds
rippleSize: 100, // Maximum ripple size
rippleWidth: 2, // Width of ripple circle
velocityColorThreshold: 0.2, // Threshold for velocity-based color joining
baseExplosionForce: 2, // Base force for explosions
baseExplosionRadius: 100, // Base explosion radius
densityAbsorptionBonus: 0.1, // How much density increases absorption chance
minAbsorptionChance: 0.2, // Minimum chance to absorb on collision
explosionInfectionChance: 0.05, // 5% chance for explosive infection
explosionColorLinger: 5, // How long explosion-colored particles keep their color
};
#ripples = [];
constructor() {
this.#initCanvas();
this.#initParticles();
this.#bindEvents();
this.#bindClickEvent();
this.animate();
}
#initCanvas() {
this.#canvas = document.getElementById('particle-canvas');
this.#ctx = this.#canvas.getContext('2d');
this.#setCanvasSize();
}
#setCanvasSize() {
this.#canvas.width = window.innerWidth;
this.#canvas.height = window.innerHeight;
}
#initParticles() {
for (let i = 0; i < this.#settings.count; i++) {
this.#particles.push({
x: Math.random() * this.#canvas.width,
y: Math.random() * this.#canvas.height,
vx: (Math.random() - 0.5) * this.#settings.speed,
vy: (Math.random() - 0.5) * this.#settings.speed,
size: this.#settings.size
});
}
}
#bindEvents() {
window.addEventListener('resize', () => {
this.#setCanvasSize();
});
// Zen-inspired reactive controls
document.getElementById('particle-count').addEventListener('input', (e) => {
const newCount = parseInt(e.target.value);
const diff = newCount - this.#particles.length;
if (diff > 0) {
for (let i = 0; i < diff; i++) {
this.#particles.push({
x: Math.random() * this.#canvas.width,
y: Math.random() * this.#canvas.height,
vx: (Math.random() - 0.5) * this.#settings.speed,
vy: (Math.random() - 0.5) * this.#settings.speed,
size: this.#settings.size
});
}
} else {
this.#particles.splice(newCount, Math.abs(diff));
}
});
document.getElementById('particle-speed').addEventListener('input', (e) => {
const speed = parseFloat(e.target.value);
this.#particles.forEach(p => {
const angle = Math.atan2(p.vy, p.vx);
const velocity = speed;
p.vx = Math.cos(angle) * velocity;
p.vy = Math.sin(angle) * velocity;
});
this.#settings.speed = speed;
});
document.getElementById('connection-distance').addEventListener('input', (e) => {
this.#settings.connectionDistance = parseInt(e.target.value);
});
document.getElementById('particle-size').addEventListener('input', (e) => {
this.#settings.size = parseFloat(e.target.value);
this.#particles.forEach(p => p.size = this.#settings.size);
});
document.getElementById('max-connections').addEventListener('input', (e) => {
this.#settings.maxConnectionsPerParticle = parseInt(e.target.value);
});
document.getElementById('max-spread').addEventListener('input', (e) => {
this.#settings.maxColorSpreadCount = parseInt(e.target.value);
});
document.getElementById('color-lifetime').addEventListener('input', (e) => {
this.#settings.colorLifetime = parseFloat(e.target.value);
});
document.getElementById('spread-speed').addEventListener('input', (e) => {
this.#settings.spreadSpeed = parseFloat(e.target.value);
this.#settings.colorPropagationSpeed = this.#settings.spreadSpeed;
});
document.getElementById('colony-size').addEventListener('input', (e) => {
this.#settings.colonySize = parseInt(e.target.value);
});
document.getElementById('join-speed').addEventListener('input', (e) => {
this.#settings.velocityColorThreshold = parseFloat(e.target.value);
});
}
#bindClickEvent() {
this.#canvas.addEventListener('click', (e) => {
const rect = this.#canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const color = `hsl(${Math.random() * 360}, 80%, 50%)`;
// Create ripple effect
this.#createRipple(x, y, color);
// Existing particle coloring logic
this.#particles.forEach(p => {
const dx = p.x - x;
const dy = p.y - y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.clickEffectRadius) {
p.color = color;
p.colorProgress = 1;
p.colorStartTime = performance.now();
}
});
});
}
#updateParticleColors() {
const currentTime = performance.now();
// Track bundles that need to explode
const bundlesToExplode = new Map();
this.#particles.forEach(p => {
if (p.colorProgress > 0) {
if (p.isExplosionColored) {
// Explosion-colored particles fade more quickly
p.colorProgress -= this.#settings.colorFadeRate * 1.5;
} else if (p.inBundle && p.bundleFormedTime) {
const bundleAge = (currentTime - p.bundleFormedTime) / 1000;
if (bundleAge > this.#settings.clusterLifetime) {
if (p.colorProgress > 0.3 && p.colorProgress < 0.35) {
const bundle = this.#findConnectedParticles(p);
if (bundle.size >= this.#settings.bundleThreshold) {
bundlesToExplode.set(p, Array.from(bundle));
}
}
p.colorProgress -= this.#settings.colorFadeRate * 3;
}
} else if (!p.inBundle && p.colorStartTime &&
(currentTime - p.colorStartTime) / 1000 > this.#settings.colorLifetime) {
p.colorProgress -= this.#settings.colorFadeRate * 2;
} else if (!p.inBundle) {
p.colorProgress -= this.#settings.colorFadeRate;
}
if (p.colorProgress <= 0) {
delete p.color;
delete p.colorProgress;
delete p.colorStartTime;
delete p.bundleFormedTime;
delete p.inBundle;
delete p.isExplosionColored;
}
}
});
bundlesToExplode.forEach((bundle, p) => {
this.#explodeColony(bundle);
});
}
#countColorSpread(color) {
return this.#particles.reduce((count, p) => {
return count + ((p.color === color || p.incomingColor === color) ? 1 : 0);
}, 0);
}
#checkColonySurvival(particle) {
if (!particle.color) return false;
let colonyCount = 1; // Start with this particle
const checked = new Set([particle]);
const toCheck = [particle];
// Breadth-first search for connected same-color particles
while (toCheck.length > 0 && colonyCount < this.#settings.colonySize) {
const current = toCheck.shift();
// Check all particles for connections
this.#particles.forEach(p => {
if (checked.has(p)) return;
const dx = p.x - current.x;
const dy = p.y - current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.connectionDistance &&
p.color === current.color) {
checked.add(p);
toCheck.push(p);
colonyCount++;
}
});
}
return colonyCount >= this.#settings.colonySize;
}
#updateSpringForces(bundle) {
bundle.forEach(p1 => {
// Reset accumulated pull direction for this particle
p1.pullX = 0;
p1.pullY = 0;
let connectionCount = 0;
bundle.forEach(p2 => {
if (p1 === p2) return;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
// Calculate spring force
const force = (distance - this.#settings.springLength) * this.#settings.springStrength;
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
// Apply forces with damping
p1.vx += fx;
p1.vy += fy;
p2.vx -= fx;
p2.vy -= fy;
});
// Calculate pull from external connections
this.#particles.forEach(p2 => {
if (bundle.includes(p2)) return; // Skip bundle members
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.connectionDistance) {
p1.pullX += dx / distance;
p1.pullY += dy / distance;
connectionCount++;
}
});
// Apply normalized pull force if there are external connections
if (connectionCount > 0) {
const pullMagnitude = Math.sqrt(p1.pullX * p1.pullX + p1.pullY * p1.pullY);
if (pullMagnitude > 0) {
p1.vx += (p1.pullX / pullMagnitude) * this.#settings.bundlePullStrength;
p1.vy += (p1.pullY / pullMagnitude) * this.#settings.bundlePullStrength;
}
}
// Apply damping only to spring forces, not to directional movement
p1.vx *= this.#settings.springDamping;
p1.vy *= this.#settings.springDamping;
});
}
#handleBundleCollisions(p, bundles) {
bundles.forEach(bundle => {
// Calculate bundle density (particles per area)
const bundleRadius = this.#getBundleRadius(bundle);
const bundleArea = Math.PI * bundleRadius * bundleRadius;
const density = bundle.length / bundleArea;
// Increase absorption chance based on density
const absorptionBonus = density * this.#settings.densityAbsorptionBonus;
const absorptionChance = Math.min(0.8, this.#settings.minAbsorptionChance + absorptionBonus);
bundle.forEach(bp => {
const dx = p.x - bp.x;
const dy = p.y - bp.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.collisionRadius) {
const relativeVelocity = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
const baseVelocity = this.#settings.speed;
const velocityRatio = relativeVelocity / baseVelocity;
// Check for absorption based on velocity and density
if (!p.color && (velocityRatio > this.#settings.velocityColorThreshold ||
Math.random() < absorptionChance)) {
p.color = bp.color;
p.colorProgress = 1;
p.colorStartTime = performance.now();
if (bp.bundleFormedTime) {
p.bundleFormedTime = bp.bundleFormedTime;
}
}
// Regular collision response
const angle = Math.atan2(dy, dx);
const targetX = bp.x + Math.cos(angle) * this.#settings.collisionRadius;
const targetY = bp.y + Math.sin(angle) * this.#settings.collisionRadius;
p.x = targetX;
p.y = targetY;
const dot = p.vx * Math.cos(angle) + p.vy * Math.sin(angle);
p.vx = p.vx - 2 * dot * Math.cos(angle);
p.vy = p.vy - 2 * dot * Math.sin(angle);
p.vx *= 0.8;
p.vy *= 0.8;
}
});
});
}
#findBundles() {
const bundles = [];
const checked = new Set();
this.#particles.forEach(p => {
if (checked.has(p) || !p.color) return;
const bundle = this.#findConnectedParticles(p);
if (bundle.size >= this.#settings.bundleThreshold) {
const bundleArray = Array.from(bundle);
const now = performance.now();
bundleArray.forEach(bp => {
bp.inBundle = true;
bp.vx *= 0.1;
bp.vy *= 0.1;
// Add formation timestamp if not already set
if (!bp.bundleFormedTime) {
bp.bundleFormedTime = now;
}
});
bundles.push(bundleArray);
}
bundle.forEach(bp => checked.add(bp));
});
return bundles;
}
#findConnectedParticles(particle) {
const connected = new Set([particle]);
const toCheck = [particle];
while (toCheck.length > 0) {
const current = toCheck.shift();
this.#particles.forEach(p => {
if (connected.has(p) || p.color !== current.color) return;
const dx = p.x - current.x;
const dy = p.y - current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.connectionDistance) {
connected.add(p);
toCheck.push(p);
}
});
}
return connected;
}
#emitFromParticle(particle) {
const color = `hsl(${Math.random() * 360}, 80%, 50%)`;
// Create ripple effect at emission point
this.#createRipple(particle.x, particle.y, color);
// Existing emission logic
this.#particles.forEach(p => {
if (p === particle) return;
const dx = p.x - particle.x;
const dy = p.y - particle.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < this.#settings.emissionRadius) {
p.color = color;
p.colorProgress = 1;
p.colorStartTime = performance.now();
const angle = Math.atan2(dy, dx);
const force = 0.5 * (1 - distance / this.#settings.emissionRadius);
p.vx += Math.cos(angle) * force;
p.vy += Math.sin(angle) * force;
}
});
}
#createRipple(x, y, color) {
this.#ripples.push({
x,
y,
color,
startTime: performance.now(),
progress: 0
});
}
#drawRipples() {
const currentTime = performance.now();
// Update and draw ripples
this.#ripples = this.#ripples.filter(ripple => {
const age = (currentTime - ripple.startTime) / 1000;
if (age > this.#settings.rippleLifetime) return false;
// Calculate ripple progress (0 to 1)
ripple.progress = age / this.#settings.rippleLifetime;
// Draw ripple
this.#ctx.beginPath();
this.#ctx.strokeStyle = this.#adjustColorOpacity(
ripple.color,
0.5 * (1 - ripple.progress)
);
this.#ctx.lineWidth = this.#settings.rippleWidth * (1 - ripple.progress);
this.#ctx.arc(
ripple.x,
ripple.y,
this.#settings.rippleSize * ripple.progress,
0,
Math.PI * 2
);
this.#ctx.stroke();
return true;
});
}
animate = () => {
this.#ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
// Draw ripples first (under particles)
this.#drawRipples();
// Find and update bundles
const bundles = this.#findBundles();
bundles.forEach(bundle => this.#updateSpringForces(bundle));
// Update colors first
this.#updateParticleColors();
this.#particles.forEach((p, i) => {
if (!p.inBundle) {
// Update position only for non-bundle particles
p.x += p.vx;
p.y += p.vy;
// Handle collisions with bundles
this.#handleBundleCollisions(p, bundles);
} else {
// Update bundle particle position without friction
p.x += p.vx;
p.y += p.vy;
}
// Bounce off edges for all particles
if (p.x < 0) {
p.x = 0;
p.vx = Math.abs(p.vx) * this.#settings.bundleBounceDamping;
}
if (p.x > this.#canvas.width) {
p.x = this.#canvas.width;
p.vx = -Math.abs(p.vx) * this.#settings.bundleBounceDamping;
}
if (p.y < 0) {
p.y = 0;
p.vy = Math.abs(p.vy) * this.#settings.bundleBounceDamping;
}
if (p.y > this.#canvas.height) {
p.y = this.#canvas.height;
p.vy = -Math.abs(p.vy) * this.#settings.bundleBounceDamping;
}
// Draw particle with color if it exists
this.#ctx.beginPath();
if (p.color && p.colorProgress > 0) {
this.#ctx.fillStyle = this.#adjustColorOpacity(p.color, p.colorProgress);
} else {
this.#ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
}
this.#ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
this.#ctx.fill();
// Track connections for this particle
let connectionCount = 0;
// Connect nearby particles with subtle lines and handle color propagation
for (let j = i + 1; j < this.#particles.length; j++) {
const p2 = this.#particles[j];
const dx = p2.x - p.x;
const dy = p2.y - p.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Check if we've hit the connection limit for either particle
if (connectionCount >= this.#settings.maxConnectionsPerParticle) {
break;
}
if (distance < this.#settings.connectionDistance) {
this.#ctx.beginPath();
// Handle color propagation between connected particles
if (p.color && p.colorProgress > 0 && !p2.color) {
if (!p.inBundle) { // Only non-bundle particles can spread color
const currentSpread = this.#countColorSpread(p.color);
if (currentSpread < this.#settings.maxColorSpreadCount) {
if (!p2.incomingColor) {
p2.incomingColor = p.color;
p2.colorProgress = 0;
} else if (p2.incomingColor === p.color) {
p2.colorProgress += this.#settings.spreadSpeed;
if (p2.colorProgress >= 1) {
p2.color = p2.incomingColor;
p2.colorStartTime = performance.now();
delete p2.incomingColor;
}
}
}
}
} else if (p.color && p2.color && p.color !== p2.color) {
// Colors clash - both lose their color
delete p.color;
delete p2.color;
delete p.colorProgress;
delete p2.colorProgress;
delete p.incomingColor;
delete p2.incomingColor;
}
// Draw connection line with gradient if colors are propagating
if (p.color || p2.color) {
const gradient = this.#ctx.createLinearGradient(p.x, p.y, p2.x, p2.y);
gradient.addColorStop(0, this.#getParticleColor(p));
gradient.addColorStop(1, this.#getParticleColor(p2));
this.#ctx.strokeStyle = gradient;
} else {
this.#ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
}
this.#ctx.moveTo(p.x, p.y);
this.#ctx.lineTo(p2.x, p2.y);
this.#ctx.stroke();
connectionCount++;
}
}
});
requestAnimationFrame(this.animate);
}
#adjustColorOpacity(color, opacity) {
// Ensure opacity is between 0 and 1
opacity = Math.max(0, Math.min(1, opacity));
if (color.startsWith('hsl')) {
return color.replace('hsl', 'hsla').replace(')', `, ${opacity})`);
}
// Fallback to white with opacity if color is invalid
return `rgba(255, 255, 255, ${opacity})`;
}
#getParticleColor(particle) {
if (particle.color && particle.colorProgress > 0) {
return this.#adjustColorOpacity(particle.color, particle.colorProgress);
}
if (particle.incomingColor && particle.colorProgress >= 0) {
return this.#adjustColorOpacity(particle.incomingColor, particle.colorProgress);
}
// Always return a valid color string
return 'rgba(255, 255, 255, 0.1)';
}
#explodeColony(bundle) {
const explosionScale = Math.sqrt(bundle.length) / 2;
const explosionRadius = this.#settings.baseExplosionRadius * explosionScale;
const explosionForce = this.#settings.baseExplosionForce * explosionScale;
const center = bundle.reduce((acc, p) => {
acc.x += p.x;
acc.y += p.y;
return acc;
}, { x: 0, y: 0 });
center.x /= bundle.length;
center.y /= bundle.length;
// Create ripple effect for explosion
this.#createRipple(center.x, center.y, bundle[0].color);
// Determine if this explosion is infectious
const isInfectious = Math.random() < this.#settings.explosionInfectionChance;
this.#particles.forEach(p => {
const dx = p.x - center.x;
const dy = p.y - center.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < explosionRadius) {
// Apply explosion force
const force = explosionForce * (1 - distance / explosionRadius);
const angle = Math.atan2(dy, dx);
p.vx += Math.cos(angle) * force;
p.vy += Math.sin(angle) * force;
// Only color particles if explosion is infectious
if (isInfectious && Math.random() < 0.3) {
p.color = bundle[0].color;
p.colorProgress = 1;
p.colorStartTime = performance.now();
p.isExplosionColored = true; // Mark as explosion-colored
}
}
});
}
#getBundleRadius(bundle) {
if (bundle.length === 0) return 0;
const center = bundle.reduce((acc, p) => {
acc.x += p.x;
acc.y += p.y;
return acc;
}, { x: 0, y: 0 });
center.x /= bundle.length;
center.y /= bundle.length;
// Find the furthest particle from center
return Math.max(...bundle.map(p => {
const dx = p.x - center.x;
const dy = p.y - center.y;
return Math.sqrt(dx * dx + dy * dy);
}));
}
}
// Initialize the particle system when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
new ParticleSystem();
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment