Education

Cyclotron Motion

Interactive simulation of charged particles in magnetic and electric fields. Explore circular orbits, E×B drift, particle acceleration, and magnetic confinement.

+1
Particle
Force
Velocity
Trail
Current Scenario:

Shows circular motion of a charged particle in a uniform magnetic field perpendicular to its velocity.

0.00
Orbital Radius (m)
0.00
Cyclotron Freq (Hz)
0.00
Period (s)
0.00
Kinetic Energy (J)
Physics Equations:

Lorentz Force: F = q(E + v×B)
Cyclotron Radius: r = mv/(|q|B)
Cyclotron Frequency: ω_c = |q|B/m
Period: T = 2π/ω_c
E×B Drift: v_drift = E×B/B²

Developer Reference

Core Algorithm & Standalone Script

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

const canvas = document.getElementById('simulationCanvas');
    const ctx = canvas.getContext('2d');

    // Get all UI elements
    const scenarioSelect = document.getElementById('scenarioSelect');
    const bFieldSlider = document.getElementById('bFieldSlider');
    const eFieldSlider = document.getElementById('eFieldSlider');
    const chargeNegBtn = document.getElementById('chargeNegBtn');
    const chargePosBtn = document.getElementById('chargePosBtn');
    const chargeInput = document.getElementById('chargeInput');
    const chargeValue = document.getElementById('chargeValue');
    const massSlider = document.getElementById('massSlider');
    const speedSlider = document.getElementById('speedSlider');
    const angleSlider = document.getElementById('angleSlider');
    const resetBtn = document.getElementById('resetBtn');
    const pauseBtn = document.getElementById('pauseBtn');

    // Simulation state
    let simState = {
      scenario: 'pure_b',
      bField: 1.0,
      eField: 0,
      charge: 1, // 1 = positive, -1 = negative
      chargeAmount: 1,
      mass: 1.0,
      speed: 2.0,
      angle: 0,
      x: 275,
      y: 275,
      vx: 0,
      vy: 0,
      trail: [],
      paused: false,
      speedMult: 1,
      time: 0,
      deeVoltage: 0,
      deePhase: 0,
      deeAngle: 0,
      gapCrossing: false
    };

    const maxTrailLength = 500;

    // Update scenario description
    function updateScenarioDescription() {
      const descriptions = {
        pure_b: 'Charged particle moves in a circular orbit due to the Lorentz force perpendicular to its velocity.',
        crossed_eb: 'A uniform electric field is applied perpendicular to the magnetic field, causing the particle to drift in the E×B direction.',
        cyclotron: 'Two D-shaped electrodes accelerate the particle at each gap crossing. The particle spirals outward as it gains energy.',
        magnetic_bottle: 'Non-uniform magnetic field (stronger at ends) traps the particle, causing it to bounce back and forth.'
      };
      document.getElementById('scenarioDescription').textContent = descriptions[simState.scenario];
    }

    // Initialize particle position
    function initializeParticle() {
      const angle = (simState.angle * Math.PI) / 180;
      simState.vx = simState.speed * Math.cos(angle);
      simState.vy = simState.speed * Math.sin(angle);
      simState.x = 275;
      simState.y = 275;
      simState.trail = [];
      simState.time = 0;
      simState.deePhase = 0;
      simState.deeAngle = 0;
    }

    // Calculate cyclotron radius
    function calculateRadius() {
      if (simState.bField === 0) return 0;
      const speed = Math.sqrt(simState.vx ** 2 + simState.vy ** 2);
      return (simState.mass * speed) / (Math.abs(simState.chargeAmount) * simState.bField);
    }

    // Calculate cyclotron frequency (rad/s)
    function calculateFrequency() {
      if (simState.bField === 0) return 0;
      return (Math.abs(simState.chargeAmount) * simState.bField) / simState.mass;
    }

    // Calculate period
    function calculatePeriod() {
      const freq = calculateFrequency();
      return freq === 0 ? 0 : (2 * Math.PI) / freq;
    }

    // Calculate kinetic energy
    function calculateKineticEnergy() {
      const speed = Math.sqrt(simState.vx ** 2 + simState.vy ** 2);
      return 0.5 * simState.mass * speed ** 2;
    }

    // Get local B field (for magnetic bottle)
    function getLocalBField(x, y) {
      if (simState.scenario !== 'magnetic_bottle') {
        return simState.bField;
      }
      // Magnetic field stronger at ends (y near 0 or 550), weaker in middle
      const centerY = 275;
      const distance = Math.abs(y - centerY);
      const maxDistance = 275;
      const ratio = distance / maxDistance;
      return simState.bField * (0.5 + 1.5 * ratio * ratio);
    }

    // Update particle physics
    function updateParticle(dt) {
      const q = simState.chargeAmount * simState.charge;
      const m = simState.mass;

      let bField = getLocalBField(simState.x, simState.y);
      let eField = simState.eField;

      // Cyclotron scenario: apply voltage in gap
      if (simState.scenario === 'cyclotron') {
        const gapWidth = 30;
        const gapAngle = Math.atan2(simState.y - 275, simState.x - 275);
        const dee1Angle = 0;
        const dee2Angle = Math.PI;

        // Check if in gap
        const angleDiff1 = Math.abs(gapAngle - dee1Angle);
        const angleDiff2 = Math.abs(gapAngle - dee2Angle);
        const minAngleDiff = Math.min(angleDiff1, angleDiff2);

        if (minAngleDiff < 0.3) {
          // Apply accelerating voltage
          const voltageAmplitude = 1.5;
          eField = voltageAmplitude;
        }
      }

      // Lorentz force: F = q(E + v×B)
      let fx = q * eField; // E field in y, but we'll use for acceleration
      let fy = 0;

      // v × B (B perpendicular to plane, pointing out)
      fx += q * simState.vy * bField;
      fy += -q * simState.vx * bField;

      // Also add E field in y direction for crossed E×B
      if (simState.scenario === 'crossed_eb') {
        fy += q * eField;
      }

      // Acceleration
      const ax = fx / m;
      const ay = fy / m;

      // Update velocity (Euler method)
      simState.vx += ax * dt;
      simState.vy += ay * dt;

      // Update position
      simState.x += simState.vx * dt;
      simState.y += simState.vy * dt;

      // Bounce off walls for magnetic bottle
      if (simState.scenario === 'magnetic_bottle') {
        const bounceMargin = 50;
        if (simState.y < bounceMargin) {
          simState.y = bounceMargin;
          simState.vy = Math.abs(simState.vy);
        }
        if (simState.y > 550 - bounceMargin) {
          simState.y = 550 - bounceMargin;
          simState.vy = -Math.abs(simState.vy);
        }
      }

      // Keep particle on canvas
      simState.x = Math.max(20, Math.min(530, simState.x));
      simState.y = Math.max(20, Math.min(530, simState.y));

      // Add to trail
      simState.trail.push({ x: simState.x, y: simState.y, t: simState.time });
      if (simState.trail.length > maxTrailLength) {
        simState.trail.shift();
      }

      simState.time += dt;
    }

    // Draw simulation
    function draw() {
      // Clear canvas
      ctx.fillStyle = '#0a0a0a';
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      // Draw field background
      if (simState.scenario === 'pure_b' || simState.scenario === 'crossed_eb' || simState.scenario === 'cyclotron') {
        ctx.fillStyle = 'rgba(0, 100, 150, 0.1)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
      }

      if (simState.scenario === 'magnetic_bottle') {
        // Draw field gradient
        const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
        gradient.addColorStop(0, 'rgba(100, 0, 150, 0.15)');
        gradient.addColorStop(0.5, 'rgba(0, 100, 150, 0.05)');
        gradient.addColorStop(1, 'rgba(100, 0, 150, 0.15)');
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
      }

      // Draw cyclotron dees
      if (simState.scenario === 'cyclotron') {
        ctx.strokeStyle = 'rgba(255, 34, 0, 0.3)';
        ctx.lineWidth = 2;
        const centerX = 275;
        const centerY = 275;
        const radius = 150;

        // Dee 1 (top)
        ctx.beginPath();
        ctx.arc(centerX, centerY, radius, 0, Math.PI, false);
        ctx.stroke();

        // Dee 2 (bottom)
        ctx.beginPath();
        ctx.arc(centerX, centerY, radius, Math.PI, 2 * Math.PI, false);
        ctx.stroke();

        // Gap lines
        ctx.strokeStyle = 'rgba(255, 34, 0, 0.5)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(centerX - 15, centerY - 10);
        ctx.lineTo(centerX - 15, centerY + 10);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(centerX + 15, centerY - 10);
        ctx.lineTo(centerX + 15, centerY + 10);
        ctx.stroke();
      }

      // Draw trail
      if (simState.trail.length > 1) {
        for (let i = 0; i < simState.trail.length - 1; i++) {
          const p1 = simState.trail[i];
          const p2 = simState.trail[i + 1];

          // Color fade based on trail age
          const age = 1 - i / simState.trail.length;
          const hue = (simState.time * 20 + i * 0.5) % 360;
          ctx.strokeStyle = `hsla(${hue}, 100%, 50%, ${age * 0.6})`;
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.moveTo(p1.x, p1.y);
          ctx.lineTo(p2.x, p2.y);
          ctx.stroke();
        }
      }

      // Draw particle
      const particleRadius = 8;
      const gradient = ctx.createRadialGradient(simState.x, simState.y, 0, simState.x, simState.y, particleRadius);
      gradient.addColorStop(0, '#00ffff');
      gradient.addColorStop(0.7, '#00d4ff');
      gradient.addColorStop(1, 'rgba(0, 212, 255, 0)');
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.arc(simState.x, simState.y, particleRadius, 0, 2 * Math.PI);
      ctx.fill();

      // Draw velocity vector
      const velocityScale = 30;
      const vLength = Math.sqrt(simState.vx ** 2 + simState.vy ** 2);
      if (vLength > 0.01) {
        const vx = (simState.vx / vLength) * velocityScale;
        const vy = (simState.vy / vLength) * velocityScale;

        // Arrow shaft
        ctx.strokeStyle = '#00c896';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(simState.x, simState.y);
        ctx.lineTo(simState.x + vx, simState.y + vy);
        ctx.stroke();

        // Arrow head
        const headlen = 6;
        const angle = Math.atan2(vy, vx);
        ctx.fillStyle = '#00c896';
        ctx.beginPath();
        ctx.moveTo(simState.x + vx, simState.y + vy);
        ctx.lineTo(simState.x + vx - headlen * Math.cos(angle - Math.PI / 6), simState.y + vy - headlen * Math.sin(angle - Math.PI / 6));
        ctx.lineTo(simState.x + vx - headlen * Math.cos(angle + Math.PI / 6), simState.y + vy - headlen * Math.sin(angle + Math.PI / 6));
        ctx.closePath();
        ctx.fill();
      }

      // Draw force vector (centripetal)
      const forceLength = Math.sqrt(simState.vx ** 2 + simState.vy ** 2);
      if (forceLength > 0.01 && simState.bField > 0) {
        const q = simState.chargeAmount * simState.charge;
        const bField = getLocalBField(simState.x, simState.y);

        // Centripetal force perpendicular to velocity
        const forceScale = 25;
        const fx = (-simState.vy / forceLength) * forceScale * (q * bField > 0 ? 1 : -1);
        const fy = (simState.vx / forceLength) * forceScale * (q * bField > 0 ? 1 : -1);

        // Arrow shaft
        ctx.strokeStyle = '#ff2200';
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(simState.x, simState.y);
        ctx.lineTo(simState.x + fx, simState.y + fy);
        ctx.stroke();

        // Arrow head
        const headlen = 6;
        const angle = Math.atan2(fy, fx);
        ctx.fillStyle = '#ff2200';
        ctx.beginPath();
        ctx.moveTo(simState.x + fx, simState.y + fy);
        ctx.lineTo(simState.x + fx - headlen * Math.cos(angle - Math.PI / 6), simState.y + fy - headlen * Math.sin(angle - Math.PI / 6));
        ctx.lineTo(simState.x + fx - headlen * Math.cos(angle + Math.PI / 6), simState.y + fy - headlen * Math.sin(angle + Math.PI / 6));
        ctx.closePath();
        ctx.fill();
      }

      // Draw E field arrows (for crossed E×B)
      if (simState.scenario === 'crossed_eb' && simState.eField > 0) {
        ctx.strokeStyle = 'rgba(245, 197, 24, 0.3)';
        ctx.lineWidth = 1;
        const spacing = 60;
        for (let y = 50; y < canvas.height; y += spacing) {
          ctx.beginPath();
          ctx.moveTo(50, y);
          ctx.lineTo(50, y + 20);
          ctx.stroke();

          // Arrow head
          ctx.fillStyle = 'rgba(245, 197, 24, 0.3)';
          ctx.beginPath();
          ctx.moveTo(50, y + 20);
          ctx.lineTo(47, y + 14);
          ctx.lineTo(53, y + 14);
          ctx.closePath();
          ctx.fill();
        }
      }

      // Draw stats
      ctx.fillStyle = '#555555';
      ctx.font = '12px DM Mono';
      let yPos = 25;

      const speed = Math.sqrt(simState.vx ** 2 + simState.vy ** 2);
      ctx.fillText(`v: ${speed.toFixed(2)}`, 10, yPos);
      yPos += 15;
      ctx.fillText(`r: ${calculateRadius().toFixed(2)}`, 10, yPos);

      if (simState.scenario === 'cyclotron') {
        ctx.fillText('◯', 510, 25);
      }
    }

    // Update stats display
    function updateStats() {
      const radius = calculateRadius();
      const frequency = calculateFrequency();
      const period = calculatePeriod();
      const energy = calculateKineticEnergy();

      document.getElementById('radiusValue').textContent = radius.toFixed(3);
      document.getElementById('frequencyValue').textContent = frequency.toFixed(3);
      document.getElementById('periodValue').textContent = period.toFixed(4);
      document.getElementById('energyValue').textContent = energy.toFixed(3);
    }

    // Animation loop
    let lastTime = Date.now();
    function animate() {
      const now = Date.now();
      const dt = Math.min((now - lastTime) / 1000, 0.05); // Cap dt at 50ms
      lastTime = now;

      if (!simState.paused) {
        updateParticle(dt * 10 * simState.speedMult); // Scale time for visibility
      }

      draw();
      updateStats();
      requestAnimationFrame(animate);
    }

    // Event listeners
    scenarioSelect.addEventListener('change', (e) => {
      simState.scenario = e.target.value;
      updateScenarioDescription();
      initializeParticle();
    });

    bFieldSlider.addEventListener('input', (e) => {
      simState.bField = parseFloat(e.target.value);
      document.getElementById('bFieldValue').textContent = simState.bField.toFixed(1);
    });

    eFieldSlider.addEventListener('input', (e) => {
      simState.eField = parseFloat(e.target.value);
      document.getElementById('eFieldValue').textContent = simState.eField.toFixed(2);
    });

    chargeNegBtn.addEventListener('click', () => {
      simState.charge = -1;
      updateChargeDisplay();
    });

    chargePosBtn.addEventListener('click', () => {
      simState.charge = 1;
      updateChargeDisplay();
    });

    chargeInput.addEventListener('input', (e) => {
      simState.chargeAmount = parseInt(e.target.value) || 1;
      updateChargeDisplay();
    });

    function updateChargeDisplay() {
      const sign = simState.charge === 1 ? '+' : '−';
      chargeValue.textContent = sign + simState.chargeAmount;
    }

    massSlider.addEventListener('input', (e) => {
      simState.mass = parseFloat(e.target.value);
      document.getElementById('massValue').textContent = simState.mass.toFixed(1);
    });

    speedSlider.addEventListener('input', (e) => {
      simState.speed = parseFloat(e.target.value);
      document.getElementById('speedValue').textContent = simState.speed.toFixed(1);
      initializeParticle();
    });

    angleSlider.addEventListener('input', (e) => {
      simState.angle = parseInt(e.target.value);
      document.getElementById('angleValue').textContent = simState.angle + '°';
      initializeParticle();
    });

    resetBtn.addEventListener('click', () => {
      initializeParticle();
      simState.paused = false;
      pauseBtn.textContent = 'Pause';
    });

    pauseBtn.addEventListener('click', () => {
      simState.paused = !simState.paused;
      pauseBtn.textContent = simState.paused ? 'Resume' : 'Pause';
    });

    document.getElementById('speedMultSlider').addEventListener('input', (e) => {
      simState.speedMult = parseFloat(e.target.value);
      document.getElementById('speedMultValue').textContent = simState.speedMult.toFixed(1) + '×';
    });

    // Initialize
    initializeParticle();
    updateScenarioDescription();
    animate();