A Pen by Dan Brickley on CodePen.
Created
June 11, 2025 22:17
-
-
Save danbri/6755593aa78da68e235454d4cc66d831 to your computer and use it in GitHub Desktop.
Sandpit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sandquake SPA</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #000; | |
| font-family: sans-serif; | |
| } | |
| #controls { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 1000; | |
| background: rgba(44, 44, 44, 0.8); | |
| padding: 15px; | |
| border-radius: 8px; | |
| color: #eee; | |
| border: 1px solid #444; | |
| } | |
| #controls label { | |
| display: block; | |
| margin-bottom: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| #controls button { | |
| width: 100%; | |
| padding: 8px; | |
| margin-top: 5px; | |
| background-color: #007bff; | |
| color: white; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| #controls button:hover { | |
| background-color: #0056b3; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| .info-canvas { | |
| position: absolute; | |
| bottom: 10px; | |
| width: 300px; | |
| height: 120px; | |
| background: rgba(0, 0, 0, 0.6); | |
| border: 1px solid #555; | |
| border-radius: 5px; | |
| } | |
| #seismograph-canvas { | |
| left: 10px; | |
| } | |
| #spectrum-canvas { | |
| right: 10px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="controls"> | |
| <label>Speed: <input type="range" id="speed-slider" min="0.1" max="5" step="0.1" value="1"></label> | |
| <label>Randomness: <input type="range" id="randomness-slider" min="0" max="1" step="0.01" value="0.5"></label> | |
| <button id="add-source">Add Source</button> | |
| <button id="remove-source">Remove Source</button> | |
| </div> | |
| <canvas id="3d-canvas"></canvas> | |
| <canvas id="seismograph-canvas" class="info-canvas"></canvas> | |
| <canvas id="spectrum-canvas" class="info-canvas"></canvas> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script> | |
| // --- CORE SIMULATION LOGIC --- | |
| /** | |
| * Manages the sandpile grid and the toppling mechanism. | |
| * This is an implementation of the Abelian sandpile model. | |
| */ | |
| class SandPile { | |
| constructor(gridSize, criticalMass) { | |
| this.gridSize = gridSize; | |
| this.criticalMass = criticalMass; | |
| this.grid = Array(gridSize).fill(null).map(() => Array(gridSize).fill(0)); | |
| this.randomnessFactor = 0.5; | |
| } | |
| setRandomnessFactor(factor) { | |
| this.randomnessFactor = factor; | |
| } | |
| addSand(x, y, amount = 1) { | |
| if (x >= 0 && x < this.gridSize && y >= 0 && y < this.gridSize) { | |
| this.grid[x][y] += amount; | |
| } | |
| } | |
| /** | |
| * Performs the toppling process for the entire grid. | |
| * @returns {number} The number of cells that toppled in this update. | |
| */ | |
| update() { | |
| let toppledCount = 0; | |
| let topple = true; | |
| while (topple) { | |
| topple = false; | |
| for (let x = 0; x < this.gridSize; x++) { | |
| for (let y = 0; y < this.gridSize; y++) { | |
| if (this.grid[x][y] >= this.criticalMass) { | |
| topple = true; | |
| toppledCount++; | |
| const toppleAmount = this.criticalMass + Math.floor(Math.random() * (this.grid[x][y] - this.criticalMass + 1) * this.randomnessFactor); | |
| this.grid[x][y] -= toppleAmount; | |
| const sandPerNeighbor = Math.floor(toppleAmount / 4); | |
| const remainder = toppleAmount % 4; | |
| this.addSand(x + 1, y, sandPerNeighbor); | |
| this.addSand(x - 1, y, sandPerNeighbor); | |
| this.addSand(x, y + 1, sandPerNeighbor); | |
| this.addSand(x, y - 1, sandPerNeighbor); | |
| this.addSand(x, y, remainder); // Add remainder back to the cell | |
| } | |
| } | |
| } | |
| } | |
| return toppledCount; | |
| } | |
| getGridCopy() { | |
| return this.grid.map(row => [...row]); | |
| } | |
| } | |
| /** | |
| * Manages the overall simulation loop, sand sources, and speed. | |
| */ | |
| class Simulation { | |
| constructor({ gridSize, criticalMass, initialSources }) { | |
| this.sandPile = new SandPile(gridSize, criticalMass); | |
| this.sources = []; | |
| this.speed = 1; | |
| this.gridSize = gridSize; | |
| for (let i = 0; i < initialSources; i++) { | |
| this.addRandomSource(); | |
| } | |
| } | |
| setGlobalSpeed(value) { this.speed = value; } | |
| getSandPile() { return this.sandPile; } | |
| getGrid() { return this.sandPile.grid; } | |
| addRandomSource() { | |
| if (this.sources.length < 20) { // Max sources | |
| this.sources.push({ | |
| x: Math.floor(Math.random() * this.gridSize), | |
| y: Math.floor(Math.random() * this.gridSize), | |
| }); | |
| } | |
| } | |
| removeSource() { | |
| if (this.sources.length > 0) { | |
| this.sources.pop(); | |
| } | |
| } | |
| update() { | |
| const sandToAdd = Math.floor(this.speed); | |
| const extraSandChance = this.speed - sandToAdd; | |
| if (Math.random() < extraSandChance) { | |
| sandToAdd++; | |
| } | |
| if (sandToAdd > 0 && this.sources.length > 0) { | |
| for (let i = 0; i < sandToAdd; i++) { | |
| const source = this.sources[Math.floor(Math.random() * this.sources.length)]; | |
| this.sandPile.addSand(source.x, source.y, 1); | |
| } | |
| } | |
| return this.sandPile.update(); | |
| } | |
| } | |
| // --- 2D CANVAS RENDERERS AND PROCESSORS --- | |
| class SeismographData { | |
| constructor(maxHistory) { | |
| this.maxHistory = maxHistory; | |
| this.data = new Array(maxHistory).fill(0); | |
| this.maxAmplitude = 1; | |
| } | |
| update(toppleCount) { | |
| this.data.shift(); | |
| this.data.push(toppleCount); | |
| if(toppleCount > this.maxAmplitude) { | |
| this.maxAmplitude = toppleCount; | |
| } else { | |
| this.maxAmplitude *= 0.995; // slowly decay max | |
| } | |
| } | |
| getSignalData() { return this.data; } | |
| getMaxAmplitude() { return this.maxAmplitude > 0 ? this.maxAmplitude : 1; } | |
| } | |
| class SeismographRenderer { | |
| constructor(canvas) { | |
| this.ctx = canvas.getContext('2d'); | |
| this.width = canvas.width; | |
| this.height = canvas.height; | |
| } | |
| update(seismographData) { | |
| const data = seismographData.getSignalData(); | |
| const maxAmplitude = seismographData.getMaxAmplitude(); | |
| this.ctx.clearRect(0, 0, this.width, this.height); | |
| this.ctx.strokeStyle = '#00ff00'; | |
| this.ctx.lineWidth = 1.5; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(0, this.height); | |
| for (let i = 0; i < data.length; i++) { | |
| const x = (i / (data.length - 1)) * this.width; | |
| const y = this.height - (data[i] / maxAmplitude) * (this.height - 10); | |
| this.ctx.lineTo(x, y); | |
| } | |
| this.ctx.stroke(); | |
| } | |
| } | |
| class FFTProcessor { | |
| constructor(bufferSize) { | |
| this.bufferSize = bufferSize; | |
| } | |
| // Basic DFT, not a true FFT, but sufficient for visualization | |
| process(signal) { | |
| const N = Math.min(this.bufferSize, signal.length); | |
| const spectrum = new Array(N / 2).fill(0); | |
| for (let k = 0; k < N / 2; k++) { | |
| let re = 0; | |
| let im = 0; | |
| for (let n = 0; n < N; n++) { | |
| const angle = (2 * Math.PI * k * n) / N; | |
| re += signal[n] * Math.cos(angle); | |
| im -= signal[n] * Math.sin(angle); | |
| } | |
| spectrum[k] = Math.sqrt(re * re + im * im) / N; | |
| } | |
| return spectrum; | |
| } | |
| } | |
| class SpectrumRenderer { | |
| constructor(canvas) { | |
| this.ctx = canvas.getContext('2d'); | |
| this.width = canvas.width; | |
| this.height = canvas.height; | |
| } | |
| update(spectrum) { | |
| this.ctx.clearRect(0, 0, this.width, this.height); | |
| const barWidth = this.width / spectrum.length; | |
| let maxMagnitude = 0; | |
| for (let i = 0; i < spectrum.length; i++) { | |
| if (spectrum[i] > maxMagnitude) maxMagnitude = spectrum[i]; | |
| } | |
| maxMagnitude = maxMagnitude > 0.1 ? maxMagnitude : 0.1; | |
| for (let i = 0; i < spectrum.length; i++) { | |
| const barHeight = (spectrum[i] / maxMagnitude) * this.height; | |
| const hue = i * 360 / spectrum.length; | |
| this.ctx.fillStyle = `hsl(${hue}, 100%, 50%)`; | |
| this.ctx.fillRect(i * barWidth, this.height - barHeight, barWidth - 1, barHeight); | |
| } | |
| } | |
| } | |
| // --- MAIN APPLICATION CLASS --- | |
| class SandquakeSPA { | |
| constructor() { | |
| this.initSimulation(); | |
| this.initRenderer(); | |
| this.initControls(); | |
| this.initSeismograph(); | |
| this.initSpectrum(); | |
| this.setupAnimation(); | |
| } | |
| initSimulation() { | |
| this.simulation = new Simulation({ | |
| gridSize: 64, | |
| criticalMass: 4, | |
| initialSources: 3, | |
| }); | |
| } | |
| initRenderer() { | |
| this.canvas = document.getElementById('3d-canvas'); | |
| this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true }); | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| window.addEventListener('resize', () => { | |
| this.renderer.setSize(window.innerWidth, window.innerHeight); | |
| this.camera.aspect = window.innerWidth / window.innerHeight; | |
| this.camera.updateProjectionMatrix(); | |
| }); | |
| this.scene = new THREE.Scene(); | |
| this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| this.camera.position.set(0, -40, 40); | |
| this.camera.up.set(0, 0, 1); | |
| this.camera.lookAt(0, 0, 0); | |
| const light = new THREE.AmbientLight(0xaaaaaa); | |
| this.scene.add(light); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); | |
| dirLight.position.set(-1, -1, 1).normalize(); | |
| this.scene.add(dirLight); | |
| this.sandMeshes = new Map(); // Use a map for efficient updates | |
| this.geometry = new THREE.BoxGeometry(1, 1, 1); | |
| } | |
| initControls() { | |
| const speedSlider = document.getElementById('speed-slider'); | |
| const randomnessSlider = document.getElementById('randomness-slider'); | |
| const addSourceButton = document.getElementById('add-source'); | |
| const removeSourceButton = document.getElementById('remove-source'); | |
| speedSlider.addEventListener('input', () => { | |
| this.simulation.setGlobalSpeed(parseFloat(speedSlider.value)); | |
| }); | |
| randomnessSlider.addEventListener('input', () => { | |
| this.simulation.sandPile.setRandomnessFactor(parseFloat(randomnessSlider.value)); | |
| }); | |
| addSourceButton.addEventListener('click', () => { | |
| this.simulation.addRandomSource(); | |
| }); | |
| removeSourceButton.addEventListener('click', () => { | |
| this.simulation.removeSource(); | |
| }); | |
| } | |
| initSeismograph() { | |
| const seismographCanvas = document.getElementById('seismograph-canvas'); | |
| this.seismographRenderer = new SeismographRenderer(seismographCanvas); | |
| this.seismographData = new SeismographData(seismographCanvas.width); | |
| } | |
| initSpectrum() { | |
| const spectrumCanvas = document.getElementById('spectrum-canvas'); | |
| this.spectrumRenderer = new SpectrumRenderer(spectrumCanvas); | |
| this.fftProcessor = new FFTProcessor(256); // FFT buffer size | |
| } | |
| update3DScene() { | |
| const sandPileGrid = this.simulation.getGrid(); | |
| const gridSize = sandPileGrid.length; | |
| const halfGrid = gridSize / 2; | |
| const seenKeys = new Set(); | |
| for (let x = 0; x < gridSize; x++) { | |
| for (let y = 0; y < gridSize; y++) { | |
| const height = sandPileGrid[x][y]; | |
| const key = `${x},${y}`; | |
| seenKeys.add(key); | |
| if (height > 0) { | |
| let mesh = this.sandMeshes.get(key); | |
| if (!mesh) { | |
| // Dynamic material color based on height | |
| const material = new THREE.MeshLambertMaterial({ color: 0xffcc00 }); | |
| mesh = new THREE.Mesh(this.geometry, material); | |
| mesh.position.set(x - halfGrid, y - halfGrid, 0); | |
| this.scene.add(mesh); | |
| this.sandMeshes.set(key, mesh); | |
| } | |
| // Update height and color | |
| mesh.scale.z = height; | |
| mesh.position.z = height / 2; | |
| mesh.material.color.setHSL(0.1, 1.0, Math.min(0.5 + height * 0.05, 1.0)); | |
| } else if (this.sandMeshes.has(key)) { | |
| // Remove mesh if height is zero | |
| this.scene.remove(this.sandMeshes.get(key)); | |
| this.sandMeshes.delete(key); | |
| } | |
| } | |
| } | |
| // Clean up any meshes that are no longer in the grid (e.g., if grid size changed) | |
| for (const key of this.sandMeshes.keys()) { | |
| if (!seenKeys.has(key)) { | |
| this.scene.remove(this.sandMeshes.get(key)); | |
| this.sandMeshes.delete(key); | |
| } | |
| } | |
| } | |
| setupAnimation() { | |
| const animate = (time) => { | |
| const toppleCount = this.simulation.update(); | |
| this.update3DScene(); | |
| this.seismographData.update(toppleCount); | |
| const seismographSignal = this.seismographData.getSignalData(); | |
| this.seismographRenderer.update(this.seismographData); | |
| const fftResult = this.fftProcessor.process(seismographSignal); | |
| this.spectrumRenderer.update(fftResult); | |
| // Simple camera rotation | |
| const timer = time * 0.00005; | |
| this.camera.position.x = Math.cos(timer * 5) * 50; | |
| this.camera.position.y = Math.sin(timer * 5) * 50; | |
| this.camera.lookAt(0, 0, 0); | |
| this.renderer.render(this.scene, this.camera); | |
| requestAnimationFrame(animate); | |
| }; | |
| animate(0); | |
| } | |
| } | |
| // Initialize the SPA | |
| new SandquakeSPA(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment