Skip to content

Instantly share code, notes, and snippets.

@danbri
Created June 11, 2025 22:17
Show Gist options
  • Select an option

  • Save danbri/6755593aa78da68e235454d4cc66d831 to your computer and use it in GitHub Desktop.

Select an option

Save danbri/6755593aa78da68e235454d4cc66d831 to your computer and use it in GitHub Desktop.
Sandpit
<!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