physics • kinetic theory

IDEAL GAS

Interactive simulation of the ideal gas law (PV = nRT) using kinetic theory. Observe how particle collisions create pressure, explore thermodynamic processes, and verify the Maxwell-Boltzmann speed distribution.

How to use: Adjust temperature, volume, or particle count. Drag the piston to change volume. Watch pressure, particle speeds, and speed distributions update in real-time.
Simulation Controls
500 K
0.60
200
Particle Box (Drag Piston →)
Color: Blue (cold) → Green (warm) → Red (hot) | Drag right edge to change volume
Pressure Gauge
Pa (Pascals) — Real-time collision rate
P vs T (Constant V)
Linear relationship: P ∝ T
P vs 1/V (Constant T)
Boyle's Law: P ∝ 1/V
Speed Distribution
Maxwell-Boltzmann curve overlay
Temperature
500
K
Pressure
0
Pa
Volume
0.6
m³ (×1e-3)
Particle Count
200
particles
PV (check)
0
Pa·m³
RMS Speed
0
m/s
Avg KE
0
J
Collision Rate
0
Hz
Mean Free Path
mm
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// Physics constants
    const k_B = 1.380649e-23; // Boltzmann constant (J/K)
    const AVOGADRO = 6.02214076e23;

    // Simulation state
    let state = {
      T: 500, // Temperature (K)
      V: 0.6, // Volume (relative, default = 1.0)
      N: 200, // Particle count
      mass: 1.0, // Particle mass (relative)
      process: 'none',
      collisionMode: 'all',
      particles: [],
      collisionCount: 0,
      frameCount: 0,
      speedHistory: [],
      pressureHistory: [],
      temperatureHistory: [],
      volumeHistory: [],
      lastCollisionTime: 0,
      initialP: 0,
      initialV: 0,
      surfaceplotVisible: false,
      pistonDragging: false,
      pistonX: 0,
      simSpeed: 1,
    };

    // Canvas elements
    const particleCanvas = document.getElementById('particleCanvas');
    const particleCtx = particleCanvas.getContext('2d');
    const pressureCanvas = document.getElementById('pressureGauge');
    const pressureCtx = pressureCanvas.getContext('2d');
    const graphPvsT = document.getElementById('graphPvsT');
    const graphPvV = document.getElementById('graphPvV');
    const graphSpeedDist = document.getElementById('graphSpeedDist');

    // Resize canvases
    function resizeCanvases() {
      particleCanvas.width = particleCanvas.offsetWidth * window.devicePixelRatio;
      particleCanvas.height = particleCanvas.offsetHeight * window.devicePixelRatio;
      particleCtx.scale(window.devicePixelRatio, window.devicePixelRatio);

      pressureCanvas.width = pressureCanvas.offsetWidth * window.devicePixelRatio;
      pressureCanvas.height = pressureCanvas.offsetHeight * window.devicePixelRatio;
      pressureCtx.scale(window.devicePixelRatio, window.devicePixelRatio);

      graphPvsT.width = graphPvsT.offsetWidth;
      graphPvsT.height = graphPvsT.offsetHeight;
      graphPvV.width = graphPvV.offsetWidth;
      graphPvV.height = graphPvV.offsetHeight;
      graphSpeedDist.width = graphSpeedDist.offsetWidth;
      graphSpeedDist.height = graphSpeedDist.offsetHeight;
    }
    resizeCanvases();
    window.addEventListener('resize', resizeCanvases);

    // Particle class
    class Particle {
      constructor(x, y, vx, vy, mass) {
        this.x = x;
        this.y = y;
        this.vx = vx;
        this.vy = vy;
        this.mass = mass;
        this.radius = 3;
      }

      speed() {
        return Math.sqrt(this.vx ** 2 + this.vy ** 2);
      }

      update(boxWidth, boxHeight, dt) {
        this.x += this.vx * dt * 100;
        this.y += this.vy * dt * 100;
      }

      collideWall(boxWidth, boxHeight) {
        const collisions = [];
        if (this.x - this.radius <= 0) {
          this.vx = Math.abs(this.vx);
          this.x = this.radius;
          collisions.push('left');
        }
        if (this.x + this.radius >= boxWidth) {
          this.vx = -Math.abs(this.vx);
          this.x = boxWidth - this.radius;
          collisions.push('right');
        }
        if (this.y - this.radius <= 0) {
          this.vy = Math.abs(this.vy);
          this.y = this.radius;
          collisions.push('top');
        }
        if (this.y + this.radius >= boxHeight) {
          this.vy = -Math.abs(this.vy);
          this.y = boxHeight - this.radius;
          collisions.push('bottom');
        }
        return collisions;
      }

      getColor() {
        const speed = this.speed();
        const maxSpeed = 500; // Normalize for color
        const norm = Math.min(speed / maxSpeed, 1);

        if (norm < 0.33) {
          // Blue to green
          const t = norm / 0.33;
          const r = Math.floor(0 + t * 0);
          const g = Math.floor(0 + t * 255);
          const b = Math.floor(255 + t * -100);
          return `rgb(${r}, ${g}, ${Math.max(0, b)})`;
        } else if (norm < 0.67) {
          // Green to yellow
          const t = (norm - 0.33) / 0.34;
          const r = Math.floor(0 + t * 255);
          const g = 255;
          const b = Math.floor(155 + t * -155);
          return `rgb(${r}, ${g}, ${Math.max(0, b)})`;
        } else {
          // Yellow to red/white
          const t = (norm - 0.67) / 0.33;
          const r = 255;
          const g = Math.floor(255 + t * -100);
          const b = Math.floor(0 + t * 100);
          return `rgb(${r}, ${Math.max(0, g)}, ${Math.min(255, b)})`;
        }
      }
    }

    // Initialize particles with Maxwell-Boltzmann distribution
    function initializeParticles() {
      state.particles = [];
      const boxWidth = particleCanvas.offsetWidth * state.V;
      const boxHeight = particleCanvas.offsetHeight;

      for (let i = 0; i < state.N; i++) {
        const x = Math.random() * (boxWidth - 12) + 6;
        const y = Math.random() * (boxHeight - 12) + 6;

        // Maxwell-Boltzmann: v ~ sqrt(kT/m)
        const sigma = Math.sqrt((k_B * state.T) / state.mass);
        const vx = (Math.random() - 0.5) * sigma * 0.1;
        const vy = (Math.random() - 0.5) * sigma * 0.1;

        state.particles.push(new Particle(x, y, vx, vy, state.mass));
      }

      state.collisionCount = 0;
      state.lastCollisionTime = performance.now();
    }

    // Update particle speeds based on temperature (Maxwell-Boltzmann)
    function updateParticleSpeeds() {
      const sigma = Math.sqrt((k_B * state.T) / state.mass);

      for (let particle of state.particles) {
        const currentSpeed = particle.speed();
        const newSpeed = sigma * (Math.random() * 2);
        if (currentSpeed > 0) {
          const scale = newSpeed / currentSpeed;
          particle.vx *= scale;
          particle.vy *= scale;
        } else {
          particle.vx = (Math.random() - 0.5) * sigma * 0.1;
          particle.vy = (Math.random() - 0.5) * sigma * 0.1;
        }
      }
    }

    // Calculate pressure from collision rate
    function calculatePressure(boxWidth, boxHeight) {
      const collisionsPerSecond = state.collisionCount / (state.frameCount / 60);
      if (collisionsPerSecond === 0) return 0;

      // P = (2/3) * (N/V) * KE_avg
      const avgKE = (3 / 2) * k_B * state.T;
      const volumeM3 = (boxWidth * boxHeight) * 1e-6; // Convert to m³
      const P = (state.N / volumeM3) * avgKE;

      return Math.max(0, P);
    }

    // Collision detection and handling
    function handleCollisions(boxWidth, boxHeight) {
      for (let particle of state.particles) {
        const wallCollisions = particle.collideWall(boxWidth, boxHeight);
        if (wallCollisions.length > 0) {
          state.collisionCount++;
        }
      }

      if (state.collisionMode === 'all') {
        for (let i = 0; i < state.particles.length; i++) {
          for (let j = i + 1; j < state.particles.length; j++) {
            const p1 = state.particles[i];
            const p2 = state.particles[j];
            const dx = p2.x - p1.x;
            const dy = p2.y - p1.y;
            const dist = Math.sqrt(dx * dx + dy * dy);
            const minDist = p1.radius + p2.radius;

            if (dist < minDist && dist > 0) {
              // Elastic collision
              const angle = Math.atan2(dy, dx);
              const sin = Math.sin(angle);
              const cos = Math.cos(angle);

              const vx1 = p1.vx * cos + p1.vy * sin;
              const vy1 = p1.vy * cos - p1.vx * sin;
              const vx2 = p2.vx * cos + p2.vy * sin;
              const vy2 = p2.vy * cos - p2.vx * sin;

              const m1 = p1.mass;
              const m2 = p2.mass;
              const newVx1 = ((m1 - m2) * vx1 + 2 * m2 * vx2) / (m1 + m2);
              const newVx2 = ((m2 - m1) * vx2 + 2 * m1 * vx1) / (m1 + m2);

              p1.vx = newVx1 * cos - vy1 * sin;
              p1.vy = newVx1 * sin + vy1 * cos;
              p2.vx = newVx2 * cos - vy2 * sin;
              p2.vy = newVx2 * sin + vy2 * cos;

              const overlap = (minDist - dist) / 2;
              p1.x -= overlap * cos;
              p1.y -= overlap * sin;
              p2.x += overlap * cos;
              p2.y += overlap * sin;

              state.collisionCount++;
            }
          }
        }
      }
    }

    // Draw particle box
    function drawParticleBox() {
      const width = particleCanvas.offsetWidth;
      const height = particleCanvas.offsetHeight;
      const boxWidth = width * state.V;

      particleCtx.fillStyle = '#111111';
      particleCtx.fillRect(0, 0, width, height);

      // Draw box outline
      particleCtx.strokeStyle = '#1e1e1e';
      particleCtx.lineWidth = 2;
      particleCtx.strokeRect(0, 0, boxWidth, height);

      // Draw piston
      particleCtx.fillStyle = 'rgba(255, 34, 0, 0.3)';
      particleCtx.fillRect(boxWidth - 10, 0, 10, height);
      particleCtx.strokeStyle = '#ff2200';
      particleCtx.lineWidth = 2;
      particleCtx.strokeRect(boxWidth - 10, 0, 10, height);

      // Draw particles
      for (let particle of state.particles) {
        particleCtx.fillStyle = particle.getColor();
        particleCtx.beginPath();
        particleCtx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
        particleCtx.fill();
      }

      state.pistonX = boxWidth;
    }

    // Draw pressure gauge
    function drawPressureGauge(pressure) {
      const width = pressureCanvas.offsetWidth;
      const height = pressureCanvas.offsetHeight;
      const centerX = width * 0.5;
      const centerY = height * 0.65;
      const radius = Math.min(width, height) * 0.3;

      pressureCtx.fillStyle = '#111111';
      pressureCtx.fillRect(0, 0, width, height);

      // Draw gauge background
      pressureCtx.fillStyle = '#161616';
      pressureCtx.beginPath();
      pressureCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
      pressureCtx.fill();

      pressureCtx.strokeStyle = '#1e1e1e';
      pressureCtx.lineWidth = 2;
      pressureCtx.beginPath();
      pressureCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
      pressureCtx.stroke();

      // Draw danger zone (red arc)
      const dangerAngle = (2 * Math.PI) * 0.3;
      pressureCtx.strokeStyle = '#ff5555';
      pressureCtx.lineWidth = 4;
      pressureCtx.beginPath();
      pressureCtx.arc(centerX, centerY, radius, Math.PI - dangerAngle * 0.5, Math.PI + dangerAngle * 0.5);
      pressureCtx.stroke();

      // Draw needle
      const maxPressure = 100000; // Pa
      const angle = Math.PI - (Math.min(pressure, maxPressure) / maxPressure) * Math.PI;
      const needleLen = radius * 0.8;
      const needleX = centerX + needleLen * Math.cos(angle);
      const needleY = centerY + needleLen * Math.sin(angle);

      pressureCtx.strokeStyle = '#ff2200';
      pressureCtx.lineWidth = 3;
      pressureCtx.beginPath();
      pressureCtx.moveTo(centerX, centerY);
      pressureCtx.lineTo(needleX, needleY);
      pressureCtx.stroke();

      // Draw center circle
      pressureCtx.fillStyle = '#ff2200';
      pressureCtx.beginPath();
      pressureCtx.arc(centerX, centerY, 6, 0, Math.PI * 2);
      pressureCtx.fill();

      // Draw labels
      pressureCtx.fillStyle = '#e8e0d5';
      pressureCtx.font = '12px "DM Mono"';
      pressureCtx.textAlign = 'center';
      pressureCtx.fillText('0 Pa', centerX - radius * 0.7, centerY + radius * 0.5);
      pressureCtx.fillText((maxPressure / 1000).toFixed(0) + 'k Pa', centerX + radius * 0.7, centerY + radius * 0.5);

      // Pressure display
      pressureCtx.font = 'bold 24px "Bebas Neue"';
      pressureCtx.fillText((pressure / 1000).toFixed(1) + ' kPa', centerX, centerY - radius * 0.8);
    }

    // Draw graphs
    function drawGraphs() {
      drawGraphPvsT();
      drawGraphPvV();
      drawSpeedDistribution();
    }

    function drawGraphPvsT() {
      const ctx = graphPvsT.getContext('2d');
      const w = graphPvsT.width;
      const h = graphPvsT.height;
      const padding = 30;

      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, w, h);

      if (state.temperatureHistory.length < 2) return;

      // Axes
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(padding, h - padding);
      ctx.lineTo(padding, padding);
      ctx.lineTo(w - padding, padding);
      ctx.stroke();

      // Labels
      ctx.fillStyle = '#555555';
      ctx.font = '10px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.fillText('T (K)', w / 2, h - 5);
      ctx.save();
      ctx.translate(10, h / 2);
      ctx.rotate(-Math.PI / 2);
      ctx.fillText('P (Pa)', 0, 0);
      ctx.restore();

      // Data line
      ctx.strokeStyle = '#ff2200';
      ctx.lineWidth = 2;
      ctx.beginPath();
      const maxT = Math.max(...state.temperatureHistory, 1);
      const maxP = Math.max(...state.pressureHistory, 1);

      for (let i = 0; i < state.temperatureHistory.length; i++) {
        const x = padding + (state.temperatureHistory[i] / maxT) * (w - 2 * padding);
        const y = h - padding - (state.pressureHistory[i] / maxP) * (h - 2 * padding);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
    }

    function drawGraphPvV() {
      const ctx = graphPvV.getContext('2d');
      const w = graphPvV.width;
      const h = graphPvV.height;
      const padding = 30;

      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, w, h);

      if (state.volumeHistory.length < 2) return;

      // Axes
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(padding, h - padding);
      ctx.lineTo(padding, padding);
      ctx.lineTo(w - padding, padding);
      ctx.stroke();

      // Labels
      ctx.fillStyle = '#555555';
      ctx.font = '10px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.fillText('1/V', w / 2, h - 5);
      ctx.save();
      ctx.translate(10, h / 2);
      ctx.rotate(-Math.PI / 2);
      ctx.fillText('P (Pa)', 0, 0);
      ctx.restore();

      // Data line
      ctx.strokeStyle = '#00c896';
      ctx.lineWidth = 2;
      ctx.beginPath();
      const invVolumes = state.volumeHistory.map(v => (v > 0 ? 1 / v : 0));
      const maxInvV = Math.max(...invVolumes, 1);
      const maxP = Math.max(...state.pressureHistory, 1);

      for (let i = 0; i < invVolumes.length; i++) {
        const x = padding + (invVolumes[i] / maxInvV) * (w - 2 * padding);
        const y = h - padding - (state.pressureHistory[i] / maxP) * (h - 2 * padding);
        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();
    }

    function drawSpeedDistribution() {
      const ctx = graphSpeedDist.getContext('2d');
      const w = graphSpeedDist.width;
      const h = graphSpeedDist.height;
      const padding = 30;

      ctx.fillStyle = '#111111';
      ctx.fillRect(0, 0, w, h);

      if (state.particles.length === 0) return;

      // Axes
      ctx.strokeStyle = '#1e1e1e';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(padding, h - padding);
      ctx.lineTo(padding, padding);
      ctx.lineTo(w - padding, padding);
      ctx.stroke();

      // Histogram
      const speeds = state.particles.map(p => p.speed());
      const maxSpeed = Math.max(...speeds, 1);
      const bins = 20;
      const binWidth = maxSpeed / bins;
      const histogram = new Array(bins).fill(0);

      for (let speed of speeds) {
        const bin = Math.floor((speed / maxSpeed) * bins);
        if (bin < bins) histogram[bin]++;
      }

      const maxCount = Math.max(...histogram, 1);

      // Draw histogram bars
      ctx.fillStyle = 'rgba(255, 34, 0, 0.5)';
      for (let i = 0; i < bins; i++) {
        const x = padding + (i / bins) * (w - 2 * padding);
        const barHeight = (histogram[i] / maxCount) * (h - 2 * padding);
        const y = h - padding - barHeight;
        const barWidth = (w - 2 * padding) / bins;
        ctx.fillRect(x, y, barWidth - 1, barHeight);
      }

      // Maxwell-Boltzmann curve
      ctx.strokeStyle = '#00c896';
      ctx.lineWidth = 2;
      ctx.beginPath();

      const sigma = Math.sqrt((k_B * state.T) / state.mass);
      for (let i = 0; i < bins; i++) {
        const v = (i / bins) * maxSpeed;
        const maxwell = 4 * Math.PI * Math.pow(state.mass / (2 * Math.PI * k_B * state.T), 1.5) * v * v * Math.exp(-(state.mass * v * v) / (2 * k_B * state.T));
        const y = h - padding - maxwell * (h - 2 * padding) * 1000;
        const x = padding + (i / bins) * (w - 2 * padding);

        if (i === 0) ctx.moveTo(x, y);
        else ctx.lineTo(x, y);
      }
      ctx.stroke();

      // Labels
      ctx.fillStyle = '#555555';
      ctx.font = '10px "DM Mono"';
      ctx.textAlign = 'center';
      ctx.fillText('Speed (m/s)', w / 2, h - 5);
    }

    // Update statistics display
    function updateStats() {
      const boxWidth = particleCanvas.offsetWidth * state.V;
      const boxHeight = particleCanvas.offsetHeight;
      const volumeM3 = (boxWidth * boxHeight) * 1e-6;

      const pressure = calculatePressure(boxWidth, boxHeight);
      const avgSpeed = state.particles.length > 0 ? state.particles.reduce((sum, p) => sum + p.speed(), 0) / state.particles.length : 0;
      const vrms = Math.sqrt(3 * k_B * state.T / state.mass);
      const avgKE = (3 / 2) * k_B * state.T;
      const pv = pressure * volumeM3;
      const collisionRate = state.frameCount > 0 ? (state.collisionCount / (state.frameCount / 60)).toFixed(1) : 0;

      // Mean free path approximation
      const particleDensity = state.N / volumeM3;
      const crossSection = Math.PI * (2e-9) ** 2; // ~2 nm diameter
      const mfp = particleDensity > 0 ? (1 / (Math.sqrt(2) * particleDensity * crossSection)) * 1e6 : Infinity;

      document.getElementById('statT').textContent = state.T;
      document.getElementById('statP').textContent = pressure.toFixed(0);
      document.getElementById('statV').textContent = (state.V * 1000).toFixed(0);
      document.getElementById('statN').textContent = state.N;
      document.getElementById('statPV').textContent = pv.toExponential(2);
      document.getElementById('statVrms').textContent = vrms.toFixed(1);
      document.getElementById('statKE').textContent = avgKE.toExponential(2);
      document.getElementById('statCollisions').textContent = collisionRate;
      document.getElementById('statMFP').textContent = mfp === Infinity ? '∞' : mfp.toFixed(2);

      // Store history
      state.pressureHistory.push(pressure);
      state.temperatureHistory.push(state.T);
      state.volumeHistory.push(state.V);
      if (state.pressureHistory.length > 500) {
        state.pressureHistory.shift();
        state.temperatureHistory.shift();
        state.volumeHistory.shift();
      }
    }

    // Animation loop
    function animate() {
      const boxWidth = particleCanvas.offsetWidth * state.V;
      const boxHeight = particleCanvas.offsetHeight;

      // Update particles
      for (let particle of state.particles) {
        particle.update(boxWidth, boxHeight, 0.016 * state.simSpeed); // ~60fps
      }

      // Handle collisions
      handleCollisions(boxWidth, boxHeight);

      // Handle process constraints
      if (state.process === 'isothermal') {
        // T constant, but if V changes, P must change
      } else if (state.process === 'isobaric') {
        // P constant - adjust T or V to maintain pressure
      } else if (state.process === 'isochoric') {
        // V constant - keep piston locked
      } else if (state.process === 'adiabatic') {
        // Q = 0: P*V^γ = constant, where γ = 5/3 for monatomic
      }

      state.frameCount++;

      // Update display every frame
      drawParticleBox();
      drawPressureGauge(calculatePressure(boxWidth, boxHeight));
      updateStats();
      drawGraphs();

      requestAnimationFrame(animate);
    }

    // UI Controls
    document.getElementById('tempSlider').addEventListener('input', (e) => {
      state.T = parseFloat(e.target.value);
      document.getElementById('tempDisplay').textContent = state.T + ' K';
      updateParticleSpeeds();
    });

    document.getElementById('volumeSlider').addEventListener('input', (e) => {
      state.V = parseFloat(e.target.value);
      document.getElementById('volumeDisplay').textContent = state.V.toFixed(2);
    });

    document.getElementById('particleSlider').addEventListener('input', (e) => {
      state.N = parseInt(e.target.value);
      document.getElementById('particleDisplay').textContent = state.N;
      initializeParticles();
    });

    document.getElementById('massSelect').addEventListener('change', (e) => {
      const massMap = { light: 0.5, medium: 1.0, heavy: 2.0 };
      state.mass = massMap[e.target.value];
      initializeParticles();
      updateParticleSpeeds();
    });

    document.getElementById('processSelect').addEventListener('change', (e) => {
      state.process = e.target.value;
    });

    document.getElementById('collisionToggle').addEventListener('change', (e) => {
      state.collisionMode = e.target.value;
    });

    document.getElementById('simSpeedSlider').addEventListener('input', (e) => {
      state.simSpeed = parseFloat(e.target.value);
      document.getElementById('simSpeedValue').textContent = state.simSpeed.toFixed(1);
    });

    document.getElementById('heatPulseBtn').addEventListener('click', () => {
      state.T = Math.min(2000, state.T + 200);
      document.getElementById('tempSlider').value = state.T;
      document.getElementById('tempDisplay').textContent = state.T + ' K';
      updateParticleSpeeds();
    });

    document.getElementById('resetBtn').addEventListener('click', () => {
      state.T = 500;
      state.V = 0.6;
      state.N = 200;
      state.process = 'none';
      state.collisionMode = 'all';
      state.speedHistory = [];
      state.pressureHistory = [];
      state.temperatureHistory = [];
      state.volumeHistory = [];
      state.collisionCount = 0;
      state.frameCount = 0;
      state.mass = 1.0;

      document.getElementById('tempSlider').value = 500;
      document.getElementById('tempDisplay').textContent = '500 K';
      document.getElementById('volumeSlider').value = 0.6;
      document.getElementById('volumeDisplay').textContent = '0.60';
      document.getElementById('particleSlider').value = 200;
      document.getElementById('particleDisplay').textContent = '200';
      document.getElementById('massSelect').value = 'medium';
      document.getElementById('processSelect').value = 'none';
      document.getElementById('collisionToggle').value = 'all';

      initializeParticles();
    });

    document.getElementById('surfacePlotToggle').addEventListener('click', () => {
      const container = document.getElementById('surfacePlotContainer');
      const btn = document.getElementById('surfacePlotToggle');
      state.surfaceplotVisible = !state.surfaceplotVisible;
      container.style.display = state.surfaceplotVisible ? 'block' : 'none';
      btn.classList.toggle('active');
    });

    // Piston dragging
    particleCanvas.addEventListener('mousedown', (e) => {
      const rect = particleCanvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      if (x > state.pistonX - 15) {
        state.pistonDragging = true;
      }
    });

    document.addEventListener('mousemove', (e) => {
      if (!state.pistonDragging) return;
      const rect = particleCanvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const maxX = particleCanvas.offsetWidth;
      const newV = Math.max(0.2, Math.min(1.0, x / maxX));
      state.V = newV;
      document.getElementById('volumeSlider').value = newV;
      document.getElementById('volumeDisplay').textContent = newV.toFixed(2);
    });

    document.addEventListener('mouseup', () => {
      state.pistonDragging = false;
    });

    // Initialize and start
    initializeParticles();
    animate();