// // ============================================ // Diffusion-limited Aggregation // ============================================ // https://twitter.com/benlorantfy // // Scroll inside the window to zoom. // Modify the configuration below to change // properties such as speed, shape, colors, etc. // // Each blue particle moves randomly each turn. If // a blue particle hits a red particle, it sticks // to it, stops moving, and turns red. // This produces some interesting fractal patterns // // https://www.youtube.com/watch?v=TU3IQRV6LV0 // https://en.wikipedia.org/wiki/Diffusion-limited_aggregation // const config = { // Wether or not to enable debugging features (ex. logging) debug: false, // The size of the particle relative to the zoom particleSize: 1.1, // If true, newer particles will be lighter and older will be darker fadeColor: true, // If true, will highlight very new particles highlightNew: true, // The color particles still moving activeColor: '#9EBFFF', // The color of stuck particles dormantColor: '#FF433F', // The shape of the particle, can be `square` or `circle` shape: "square", // The amount of time between each step stepTime: 50, // How many particles to pack into the space density: 0.5, // The width of the inital particle grouping width: 100, // The height of the inital particle grouping height: 100, // Wether or not to bound particles within the initial // grouping dimensions. If particles try to exit the bounds, // it will nudge them back towards the center bound: false, // The initial zoom level. Can be changed by scrolling zoom: 4, // Wether or not to show the current step number // in the top left showNumSteps: false, // The color of the background backgroundColor: 'black', }; class Particle { constructor({ x = 0, y = 0, moving = true, stepAttached = 0 }) { this.x = x; this.y = y; this.moving = moving; this.stepAttached = stepAttached; } } class Sim { private ctx = null; private started = false; private paused = true; private config = { debug: false, fadeColor: true, particleSize: 1.1, activeColor: '#9EBFFF', dormantColor: '#FF433F', shape: "square", stepTime: 50, density: 0.5, width: 100, height: 100, zoom: 5, bound: false, backgroundColor: "white" } private numSteps = 0; // Utility function to lighten a color by an amount // https://stackoverflow.com/a/13542669 lighten(color, percent) { const f=parseInt(color.slice(1),16),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=f>>16,G=f>>8&0x00FF,B=f&0x0000FF; return "#"+(0x1000000+(Math.round((t-R)*p)+R)*0x10000+(Math.round((t-G)*p)+G)*0x100+(Math.round((t-B)*p)+B)).toString(16).slice(1); } // Used to calculate how many // steps to do in each simulation loop // Since raf doesn't gaurentee an interval, // we have to calculate how much time has passed private lastTime = null; private timeSinceLastStep = null; // Stores all the particles and their positions // Use hash for O(1) lookups, key is coords: `${x}x${y}` private lattice = {}; constructor(canvas, config = {}) { this.canvas = canvas; this.config = Object.assign({}, this.config, config); this.ctx = canvas.getContext('2d'); this.resize(); } /* * Allows the user to zoom the simulation given a change in zoom */ zoom(delta) { this.config.zoom += delta / 100; if(this.config.zoom < 1){ this.config.zoom = 1; } } /* * Allows user to resize the animation to match the size * of the canvas */ resize() { const canvas = this.canvas; this.canvasWidth = canvas.offsetWidth; this.canvasHeight = canvas.offsetHeight; canvas.setAttribute("width", this.canvasWidth); canvas.setAttribute("height", this.canvasHeight); } private clear() { this.ctx.beginPath(); this.ctx.rect(0, 0, this.canvasWidth, this.canvasHeight); this.ctx.fillStyle = this.config.backgroundColor; this.ctx.fill(); this.ctx.closePath(); } private draw = () => { this.clear(); const { zoom, width, height, shape, particleSize, highlightColor, dormantColor, activeColor } = this.config; Object.keys(this.lattice).forEach((coords) => { const particles = this.lattice[coords]; particles.forEach((p) => { const veryNew = this.numSteps - p.stepAttached < 10; if (p.moving) { this.ctx.fillStyle = activeColor; } else if (this.config.highlightNew && veryNew){ this.ctx.fillStyle = dormantColor; } else if (this.config.fadeColor){ let percent = p.stepAttached / this.numSteps; if (percent > 0.7) percent = 0.7; this.ctx.fillStyle = this.lighten(dormantColor, percent); } else { this.ctx.fillStyle = dormantColor; } this.ctx.beginPath(); if (shape === "circle") { this.ctx.arc( p.x * zoom + (this.canvasWidth / 2) - (width * zoom / 2), p.y * zoom + (this.canvasHeight / 2) - (height * zoom / 2), particleSize / 2 * zoom, 0, 2 * Math.PI ); } else if (shape === "square" || shape === "rectangle") { this.ctx.rect( p.x * zoom + (this.canvasWidth / 2) - (width * zoom / 2), p.y * zoom + (this.canvasHeight / 2) - (height * zoom / 2), particleSize * zoom, particleSize * zoom ); } this.ctx.fill(); this.ctx.closePath(); }); }) if (this.config.showNumSteps) { this.ctx.fillStyle = 'rgb(33,33,33)'; this.ctx.font = "24px Arial"; this.ctx.fillText(this.numSteps, 10, 36); } requestAnimationFrame(this.draw); } private generate() { this.log("Generating initial particle positions..."); const { width, height, density } = this.config; const middleX = Math.floor(width / 2); const middleY = Math.floor(height / 2); const delta = 1 / density; for (let x = 0; x < width; x += delta) { for (let y = 0; y < height; y += delta) { let moving = true; const isCenterX = x === middleX || (x - delta < middleX && x > middleX); const isCenterY = y === middleY || (y - delta < middleY && y > middleY); if (isCenterX && isCenterY) { this.log(`Found center: ${x},${y}`); moving = false; } this.lattice[`${x}x${y}`] = [new Particle({ x, y, moving })]; } } this.log("Done initial generation"); } /** * Returns a random integer between min (inclusive) and max (inclusive) * Using Math.round() will give you a non-uniform distribution! * https://stackoverflow.com/a/1527820 */ private getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } private simulate = (time = 0) => { if (!this.lastTime) { this.lastTime = time; this.timeSinceLastStep = 0; } const { stepTime } = this.config; // Calculates the time between simulate calls // (ussually between 16 - 33ms) const elapsedTime = time - this.lastTime; // Keeps track of the time since the last simulation step this.timeSinceLastStep += elapsedTime; // Get's the number of steps the simulation is behind by // For example, if there's been 3 seconds since the last step, // we need to run 3 simulation steps to catch up let numStepsToRun = Math.floor(this.timeSinceLastStep / stepTime); // Don't run too many steps because it causes the browser to freeze if (numStepsToRun > 5) { this.log(`Too many steps (${numStepsToRun}). This would probably cause the browser to freeze, so we're only going to do 1 step`); numStepsToRun = 1; this.lastTime = null; this.timeSinceLastStep = 1 * stepTime; } // Resets the time since the last step to about 0 // It's not perfectly zero, because some time might still be // ellapsing for the next step // For example, if the time since the last step is 4.5 seconds, // we want to do 4 steps and then set the time since last step to .5s this.timeSinceLastStep -= numStepsToRun * stepTime; for (let i = 0; i < numStepsToRun; i++) { this.step(); } this.lastTime = time; this.simulationLoop = requestAnimationFrame(this.simulate); } private step() { const { width, height, bound } = this.config; const newLattice = {}; Object.keys(this.lattice).forEach((coords) => { const particles = this.lattice[coords]; particles.forEach((p) => { let stepAttached = p.stepAttached; let moving = p.moving; const direction = this.getRandomInt(1, 4); let x = p.x; let y = p.y; if (bound) { if (x >= width && direction === 2) { direction = 4; } if (x < 0 && direction === 4) { direction = 2; } if (y < 0 && direction === 1) { direction = 3; } if (y >= height && direction === 3) { direction = 1; } } // 1 // 4 _|_ 2 // | // 3 if (moving) { if (direction === 1) { y -= 1; } else if (direction === 2) { x += 1; } else if (direction === 3) { y += 1; } else if (direction === 4) { x -= 1; } } // Check all this particles neighbours to see if it should stick if (moving) { const neighbours = this.getNeighbours(x, y); neighbours.forEach((neighbour) => { if (!neighbour.moving) { moving = false; stepAttached = this.numSteps; } }); } // Add the new point to the new lattice // Use a new lattice instead of the old lattice so // we don't accidently process the same particle twice const particle = new Particle({ x, y, moving, stepAttached }); if (!newLattice[`${x}x${y}`]) { newLattice[`${x}x${y}`] = [particle]; } else { newLattice[`${x}x${y}`].push(particle); } }); }); this.lattice = newLattice; this.numSteps++; } private getNeighbours(x, y) { const left = this.lattice[`${x - 1}x${y}`] || []; const top = this.lattice[`${x}x${y - 1}`] || []; const bottom = this.lattice[`${x}x${y + 1}`] || []; const right = this.lattice[`${x + 1}x${y}`] || []; return [].concat(left, top, bottom, right); } private log(...args) { if (this.config.debug) { console.log(`[SIM]`, ...args); } } start() { if (!this.started) { this.generate(); this.simulate(); this.draw(); this.started = true; this.paused = false; } else { this.resume(); } } pause() { if (!this.paused) { this.log("Pausing simulation loop"); cancelAnimationFrame(this.simulationLoop); this.paused = true; this.lastTime = this.timeSinceLastStep = null; } } resume() { if (this.paused) { this.log("Resuming simulation loop"); this.simulate(); this.paused = false; } } } /* * Initilize the simulation with the canvas instance and * a config object which is merged with a default config */ const sim = new Sim(document.getElementsByTagName("canvas")[0], config); // Resize the simulation when the window is resized so the // canvas doesn't appear to stretch window.addEventListener("resize", () => { sim.resize(); }); // Let the user change the zoom by scrolling window.addEventListener("mousewheel",function(e){ e.preventDefault(); const delta = e.wheelDelta; sim.zoom(delta); }); // Start the simulation sim.start();

!