Physics Lab

Bernoulli Effect

Interactive simulation of Bernoulli's Principle and fluid dynamics. Explore pipe flow, airfoils, Torricelli's theorem, and Magnus effect.

Pipe Flow & Venturi Effect

Observe how flow speed increases in narrower sections while pressure decreases (Bernoulli's equation & continuity).

Bernoulli: P + ½ρv² + ρgh = constant
Continuity: A₁v₁ = A₂v₂
60
30
2.0
1000
v₁ (wide section)
2.0 m/s
v₂ (narrow section)
8.0 m/s
P₁ (wide section)
100.0 kPa
P₂ (narrow section)
98.0 kPa
✓ Continuity check: 2832 = 2832 (A₁v₁ = A₂v₂)
Slow flow (blue)
Fast flow (red)
Pressure gauge

Airfoil Lift Generation

Curved upper surface creates faster flow and lower pressure above, generating net upward lift force.

Lift = ½ρv²S·CL
Bernoulli shows: P_lower > P_upper → net upward force
5
30
30
Lift Coefficient (CL)
0.82
Lift Force (N)
14,850 N
ΔP (Upper-Lower)
-990 Pa
Flow Status
Attached
Low pressure (fast)
High pressure (slow)
Lift force

Torricelli's Theorem

Water efflux velocity from a hole depends on the height of water column above it: v = √(2gh)

Torricelli: v = √(2gh)
where g = 9.81 m/s², h = water height above hole
1.0
0.3
15
1.0x
Efflux Velocity (v)
4.43 m/s
Jet Range
1.86 m
Flow Rate (Q)
0.79 L/s
Time to Empty
2.5 min
Water
Jet stream

Magnus Effect

Spinning objects deflect fluid flow, creating pressure asymmetry and curved motion (curveball effect).

Magnus Force: FM = ½ρv²S·CL(spin)
Faster flow on one side → lower pressure → curved trajectory
30
2000
3.7
Spin Rate (ω)
209.4 rad/s
Magnus Force
18.2 N
Deflection Distance
0.85 m
Flight Time
1.33 s
Slower flow side
Faster flow side
Curved trajectory
Developer Reference

Core Algorithm & Standalone Script

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

// Tab switching
    document.querySelectorAll('.tab').forEach(tab => {
      tab.addEventListener('click', () => {
        document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
        document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
        tab.classList.add('active');
        document.getElementById(tab.dataset.tab).classList.add('active');
      });
    });

    // ===== SCENARIO 1: PIPE FLOW =====
    const pipeCanvas = document.getElementById('pipeCanvas');
    const pipeCtx = pipeCanvas.getContext('2d');
    let pipeAnimFrame = 0;

    const pipeInputs = {
      D1: document.getElementById('pipeD1'),
      D2: document.getElementById('pipeD2'),
      V1: document.getElementById('pipeV1'),
      density: document.getElementById('pipeDensity')
    };

    function updatePipeValues() {
      document.getElementById('pipeD1Val').textContent = pipeInputs.D1.value;
      document.getElementById('pipeD2Val').textContent = pipeInputs.D2.value;
      document.getElementById('pipeV1Val').textContent = parseFloat(pipeInputs.V1.value).toFixed(1);
      document.getElementById('pipeDensityVal').textContent = pipeInputs.density.value;
    }

    function drawPipeFlow() {
      const D1 = parseFloat(pipeInputs.D1.value);
      const D2 = parseFloat(pipeInputs.D2.value);
      const V1 = parseFloat(pipeInputs.V1.value);
      const rho = parseFloat(pipeInputs.density.value);

      // Calculate derived values
      const A1 = Math.PI * (D1/2) ** 2;
      const A2 = Math.PI * (D2/2) ** 2;
      const V2 = V1 * A1 / A2;
      const P0 = 100000; // reference pressure (Pa)
      const P1 = P0;
      const P2 = P1 - 0.5 * rho * (V2**2 - V1**2);

      // Update stats
      document.getElementById('pipeStatV1').textContent = V1.toFixed(2) + ' m/s';
      document.getElementById('pipeStatV2').textContent = V2.toFixed(2) + ' m/s';
      document.getElementById('pipeStatP1').textContent = (P1/1000).toFixed(1) + ' kPa';
      document.getElementById('pipeStatP2').textContent = (P2/1000).toFixed(1) + ' kPa';

      const continuityErr = Math.abs((A1*V1) - (A2*V2)) / (A1*V1);
      const continuityEl = document.getElementById('pipeContinuity');
      if (continuityErr < 0.01) {
        continuityEl.classList.remove('fail');
        continuityEl.innerHTML = `✓ Continuity check: ${(A1*V1).toFixed(0)} ≈ ${(A2*V2).toFixed(0)} (A₁v₁ = A₂v₂)`;
      } else {
        continuityEl.classList.add('fail');
        continuityEl.innerHTML = `✗ Continuity error: ${(continuityErr*100).toFixed(1)}%`;
      }

      // Clear canvas
      pipeCtx.fillStyle = '#0a0a0a';
      pipeCtx.fillRect(0, 0, pipeCanvas.width, pipeCanvas.height);

      // Draw pipe sections
      const centerY = pipeCanvas.height / 2;
      const sectionWidth = pipeCanvas.width / 3;

      // Section 1: Wide inlet
      const h1 = (D1 / 100) * 80;
      pipeCtx.strokeStyle = '#e8e0d5';
      pipeCtx.lineWidth = 2;
      pipeCtx.beginPath();
      pipeCtx.moveTo(20, centerY - h1/2);
      pipeCtx.lineTo(sectionWidth, centerY - (D1*0.4));
      pipeCtx.lineTo(sectionWidth, centerY + (D1*0.4));
      pipeCtx.lineTo(20, centerY + h1/2);
      pipeCtx.closePath();
      pipeCtx.stroke();

      // Section 2: Narrow constriction
      const h2 = (D2 / 100) * 80;
      pipeCtx.beginPath();
      pipeCtx.moveTo(sectionWidth, centerY - (D1*0.4));
      pipeCtx.lineTo(2*sectionWidth, centerY - h2/2);
      pipeCtx.lineTo(2*sectionWidth, centerY + h2/2);
      pipeCtx.lineTo(sectionWidth, centerY + (D1*0.4));
      pipeCtx.closePath();
      pipeCtx.stroke();

      // Section 3: Wide outlet
      pipeCtx.beginPath();
      pipeCtx.moveTo(2*sectionWidth, centerY - h2/2);
      pipeCtx.lineTo(pipeCanvas.width - 20, centerY - h1/2);
      pipeCtx.lineTo(pipeCanvas.width - 20, centerY + h1/2);
      pipeCtx.lineTo(2*sectionWidth, centerY + h2/2);
      pipeCtx.closePath();
      pipeCtx.stroke();

      // Draw animated particles
      const particleSpeed = 200;
      pipeAnimFrame = (pipeAnimFrame + 1) % 300;

      // Particles in section 1 (slow)
      for (let i = 0; i < 5; i++) {
        const x = 20 + (pipeAnimFrame / 300) * (sectionWidth - 20) + i * (sectionWidth / 5);
        const y = centerY + (Math.sin(pipeAnimFrame / 30 + i) * 10);
        const color = '#4488ff';
        drawParticle(pipeCtx, x % (sectionWidth), y, color, 4);
      }

      // Particles in section 2 (fast)
      for (let i = 0; i < 8; i++) {
        const x = sectionWidth + (pipeAnimFrame / 150) * sectionWidth + i * (sectionWidth / 8);
        const y = centerY + (Math.sin(pipeAnimFrame / 30 + i) * 5);
        const color = '#ff2200';
        if (x < 2*sectionWidth) {
          drawParticle(pipeCtx, x, y, color, 4);
        }
      }

      // Particles in section 3 (slow again)
      for (let i = 0; i < 5; i++) {
        const x = 2*sectionWidth + (pipeAnimFrame / 300) * (sectionWidth - 20) + i * (sectionWidth / 5);
        const y = centerY + (Math.sin(pipeAnimFrame / 30 + i) * 10);
        const color = '#4488ff';
        if (x < pipeCanvas.width - 20) {
          drawParticle(pipeCtx, x, y, color, 4);
        }
      }

      // Draw pressure gauges
      const gaugeX = [sectionWidth * 0.5, sectionWidth * 1.5, sectionWidth * 2.5];
      const pressures = [P1, P2, P1];
      const pressureHeights = [
        (P1 / P0) * 100,
        (P2 / P0) * 100,
        (P1 / P0) * 100
      ];

      for (let i = 0; i < 3; i++) {
        const h = pressureHeights[i];
        const x = gaugeX[i];

        // Tube
        pipeCtx.strokeStyle = '#555555';
        pipeCtx.lineWidth = 3;
        pipeCtx.beginPath();
        pipeCtx.moveTo(x, centerY + 80);
        pipeCtx.lineTo(x, centerY + 80 - h);
        pipeCtx.stroke();

        // Liquid in tube
        pipeCtx.fillStyle = '#00c896';
        pipeCtx.fillRect(x - 4, centerY + 80 - h, 8, h);
      }

      // Labels
      pipeCtx.fillStyle = '#555555';
      pipeCtx.font = '12px DM Mono';
      pipeCtx.textAlign = 'center';
      pipeCtx.fillText('Wide', sectionWidth * 0.5, pipeCanvas.height - 20);
      pipeCtx.fillText('Narrow', sectionWidth * 1.5, pipeCanvas.height - 20);
      pipeCtx.fillText('Wide', sectionWidth * 2.5, pipeCanvas.height - 20);
    }

    function drawParticle(ctx, x, y, color, radius) {
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    Object.values(pipeInputs).forEach(input => {
      input.addEventListener('input', updatePipeValues);
    });

    function animatePipe() {
      drawPipeFlow();
      requestAnimationFrame(animatePipe);
    }
    animatePipe();

    // ===== SCENARIO 2: AIRFOIL LIFT =====
    const airfoilCanvas = document.getElementById('airfoilCanvas');
    const airfoilCtx = airfoilCanvas.getContext('2d');
    let airfoilAnimFrame = 0;

    const airfoilInputs = {
      AoA: document.getElementById('airfoilAoA'),
      V: document.getElementById('airfoilV'),
      area: document.getElementById('airfoilArea'),
      flowType: document.getElementById('airfoilFlowType')
    };

    function drawAirfoilLift() {
      const AoA = parseFloat(airfoilInputs.AoA.value);
      const V = parseFloat(airfoilInputs.V.value);
      const area = parseFloat(airfoilInputs.area.value);
      const flowType = airfoilInputs.flowType.value;

      // Calculate CL based on angle of attack
      let CL = (AoA / 10) * 1.2;
      if (AoA > 15) CL = 0.5; // stall
      if (AoA < -5) CL = -0.3;

      const rho = 1.225;
      const lift = 0.5 * rho * V * V * area * CL;
      const deltaP = 0.5 * rho * V * V * (1 - 0.5); // approximate

      // Update stats
      document.getElementById('airfoilStatCL').textContent = CL.toFixed(2);
      document.getElementById('airfoilStatLift').textContent = (lift).toFixed(0) + ' N';
      document.getElementById('airfoilStatDP').textContent = (-deltaP).toFixed(0) + ' Pa';

      const stalled = AoA > 15;
      document.getElementById('airfoilStatFlow').textContent = stalled ? 'STALLED' : 'Attached';
      document.getElementById('airfoilStatFlow').style.color = stalled ? '#ff5555' : '#00c896';

      document.getElementById('airfoilAoAVal').textContent = AoA;
      document.getElementById('airfoilVVal').textContent = V;
      document.getElementById('airfoilAreaVal').textContent = area;

      // Clear canvas
      airfoilCtx.fillStyle = '#0a0a0a';
      airfoilCtx.fillRect(0, 0, airfoilCanvas.width, airfoilCanvas.height);

      const centerX = airfoilCanvas.width / 2;
      const centerY = airfoilCanvas.height / 2;
      const scale = 60;

      // Save context for rotation
      airfoilCtx.save();
      airfoilCtx.translate(centerX, centerY);
      airfoilCtx.rotate((AoA * Math.PI) / 180);

      // Draw NACA 0012 airfoil
      airfoilCtx.strokeStyle = '#e8e0d5';
      airfoilCtx.lineWidth = 2;
      airfoilCtx.beginPath();

      for (let i = 0; i <= 100; i++) {
        const x = (i / 100) * 2 - 1;
        // NACA 0012 thickness distribution
        const thickness = 0.12 * (0.2969 * Math.sqrt(Math.abs(x)) - 0.1260 * x - 0.3516 * x*x + 0.2843 * x*x*x - 0.1015 * x*x*x*x);
        const y = thickness;
        const px = x * scale;
        const py = y * scale;
        if (i === 0) airfoilCtx.moveTo(px, py);
        else airfoilCtx.lineTo(px, py);
      }

      for (let i = 100; i >= 0; i--) {
        const x = (i / 100) * 2 - 1;
        const thickness = 0.12 * (0.2969 * Math.sqrt(Math.abs(x)) - 0.1260 * x - 0.3516 * x*x + 0.2843 * x*x*x - 0.1015 * x*x*x*x);
        const y = -thickness;
        const px = x * scale;
        const py = y * scale;
        airfoilCtx.lineTo(px, py);
      }

      airfoilCtx.closePath();
      airfoilCtx.stroke();

      // Fill airfoil
      if (stalled) {
        airfoilCtx.fillStyle = 'rgba(255, 85, 85, 0.3)';
      } else {
        airfoilCtx.fillStyle = 'rgba(255, 34, 0, 0.2)';
      }
      airfoilCtx.fill();

      airfoilCtx.restore();

      // Draw streamlines
      const numStreamlines = 7;
      const spacing = airfoilCanvas.height / numStreamlines;

      for (let s = 0; s < numStreamlines; s++) {
        const startY = (s + 1) * spacing - centerY;
        const distFromCenter = Math.abs(startY);
        const speedFactor = 1 + (Math.max(0, 100 - distFromCenter) / 100) * 0.5;

        airfoilCtx.strokeStyle = distFromCenter < 60
          ? `rgba(68, 136, 255, ${0.3 + speedFactor * 0.3})`
          : 'rgba(68, 136, 255, 0.2)';
        airfoilCtx.lineWidth = 1.5;
        airfoilCtx.beginPath();

        for (let x = -airfoilCanvas.width / 2; x < airfoilCanvas.width / 2; x += 10) {
          const distX = Math.abs(x);
          const flow = Math.sin((distX / 10 + airfoilAnimFrame / 30) * 0.1) * 15;
          airfoilCtx.lineTo(centerX + x, centerY + startY + flow);
        }
        airfoilCtx.stroke();
      }

      // Draw lift arrow
      if (lift > 100) {
        const arrowLength = Math.min(100, lift / 200);
        airfoilCtx.strokeStyle = '#00c896';
        airfoilCtx.lineWidth = 3;
        airfoilCtx.beginPath();
        airfoilCtx.moveTo(centerX, centerY);
        airfoilCtx.lineTo(centerX, centerY - arrowLength);
        airfoilCtx.stroke();

        // Arrowhead
        airfoilCtx.fillStyle = '#00c896';
        airfoilCtx.beginPath();
        airfoilCtx.moveTo(centerX, centerY - arrowLength);
        airfoilCtx.lineTo(centerX - 10, centerY - arrowLength + 15);
        airfoilCtx.lineTo(centerX + 10, centerY - arrowLength + 15);
        airfoilCtx.closePath();
        airfoilCtx.fill();

        // Label
        airfoilCtx.fillStyle = '#00c896';
        airfoilCtx.font = 'bold 14px DM Mono';
        airfoilCtx.textAlign = 'center';
        airfoilCtx.fillText(`Lift: ${(lift/1000).toFixed(1)} kN`, centerX, centerY - arrowLength - 20);
      }

      // Draw pressure distribution
      airfoilCtx.font = '12px DM Mono';
      airfoilCtx.fillStyle = '#555555';
      airfoilCtx.textAlign = 'center';
      airfoilCtx.fillText('Upper: Fast (P↓)', centerX, 30);
      airfoilCtx.fillText('Lower: Slow (P↑)', centerX, airfoilCanvas.height - 30);

      airfoilAnimFrame++;
    }

    Object.values(airfoilInputs).forEach(input => {
      input.addEventListener('input', () => {});
    });

    function animateAirfoil() {
      drawAirfoilLift();
      requestAnimationFrame(animateAirfoil);
    }
    animateAirfoil();

    // ===== SCENARIO 3: TORRICELLI'S THEOREM =====
    const torricelliCanvas = document.getElementById('torricelliCanvas');
    const torricelliCtx = torricelliCanvas.getContext('2d');
    let torriAnimFrame = 0;

    const torriInputs = {
      h: document.getElementById('torriH'),
      hole: document.getElementById('torriHole'),
      d: document.getElementById('torriD'),
      speed: document.getElementById('torriSpeed')
    };

    function drawTorricelli() {
      const h = parseFloat(torriInputs.h.value);
      const holePos = parseFloat(torriInputs.hole.value);
      const d = parseFloat(torriInputs.d.value);
      const speedFactor = parseFloat(torriInputs.speed.value);

      const g = 9.81;
      const hAboveHole = Math.max(0, h - holePos);
      const v = Math.sqrt(2 * g * hAboveHole);
      const Q = Math.PI * (d/2000) ** 2 * v;
      const totalVolume = Math.PI * 0.3 ** 2 * h;
      const timeToEmpty = totalVolume / Q;

      // Calculate jet trajectory
      const gravity = 9.81;
      const jetRange = 2 * v / gravity * holePos; // simplified

      // Update stats
      document.getElementById('torriHVal').textContent = h.toFixed(1);
      document.getElementById('torriHoleVal').textContent = holePos.toFixed(1);
      document.getElementById('torriDVal').textContent = d;
      document.getElementById('torriSpeedVal').textContent = speedFactor.toFixed(1) + 'x';
      document.getElementById('torriStatV').textContent = v.toFixed(2) + ' m/s';
      document.getElementById('torriStatRange').textContent = jetRange.toFixed(2) + ' m';
      document.getElementById('torriStatQ').textContent = (Q * 1000).toFixed(2) + ' L/s';
      document.getElementById('torriStatTime').textContent = (timeToEmpty / 60).toFixed(1) + ' min';

      // Clear canvas
      torricelliCtx.fillStyle = '#0a0a0a';
      torricelliCtx.fillRect(0, 0, torricelliCanvas.width, torricelliCanvas.height);

      const tankX = 150;
      const tankY = 50;
      const tankW = 200;
      const tankH = h * 120;
      const holeX = tankX + tankW + 20;
      const holeY = tankY + tankH - holePos * 120;

      // Draw tank
      torricelliCtx.strokeStyle = '#e8e0d5';
      torricelliCtx.lineWidth = 3;
      torricelliCtx.strokeRect(tankX, tankY, tankW, tankH);

      // Draw water in tank
      torricelliCtx.fillStyle = 'rgba(68, 136, 255, 0.5)';
      torricelliCtx.fillRect(tankX, tankY, tankW, tankH);

      // Draw hole
      torricelliCtx.fillStyle = '#555555';
      torricelliCtx.fillRect(tankX + tankW, holeY - 5, 10, 10);

      // Draw height markers
      torricelliCtx.strokeStyle = '#555555';
      torricelliCtx.lineWidth = 1;
      torricelliCtx.font = '10px DM Mono';
      torricelliCtx.fillStyle = '#555555';
      torricelliCtx.textAlign = 'right';

      for (let i = 0; i <= h; i += 0.5) {
        const y = tankY + tankH - i * 120;
        torricelliCtx.beginPath();
        torricelliCtx.moveTo(tankX - 10, y);
        torricelliCtx.lineTo(tankX, y);
        torricelliCtx.stroke();
        if (i % 1 === 0) {
          torricelliCtx.fillText(i.toFixed(1) + 'm', tankX - 15, y + 3);
        }
      }

      // Draw jet particles
      for (let i = 0; i < 20; i++) {
        const phase = (torriAnimFrame * speedFactor + i * 15) % 300;
        const t = phase / 300;

        if (t < 2) {
          const x = holeX + v * t * 50;
          const y = holeY + 0.5 * gravity * t * t * 40 * (holePos / h);

          if (y < torricelliCanvas.height && x < torricelliCanvas.width) {
            torricelliCtx.fillStyle = '#ff2200';
            torricelliCtx.beginPath();
            torricelliCtx.arc(x, y, 3, 0, Math.PI * 2);
            torricelliCtx.fill();
          }
        }
      }

      // Draw velocity annotation
      torricelliCtx.strokeStyle = '#00c896';
      torricelliCtx.lineWidth = 2;
      torricelliCtx.beginPath();
      torricelliCtx.moveTo(holeX, holeY);
      torricelliCtx.lineTo(holeX + v * 3, holeY);
      torricelliCtx.stroke();

      torricelliCtx.fillStyle = '#00c896';
      torricelliCtx.font = '12px DM Mono';
      torricelliCtx.fillText(`v = ${v.toFixed(1)} m/s`, holeX + v * 1.5, holeY - 10);

      // Show equation
      torricelliCtx.fillStyle = '#555555';
      torricelliCtx.font = 'bold 13px DM Mono';
      torricelliCtx.textAlign = 'left';
      torricelliCtx.fillText(`v = √(2gh) = √(2 × 9.81 × ${hAboveHole.toFixed(1)})`, 450, 80);

      torriAnimFrame++;
    }

    Object.values(torriInputs).forEach(input => {
      input.addEventListener('input', () => {});
    });

    function animateTorricelli() {
      drawTorricelli();
      requestAnimationFrame(animateTorricelli);
    }
    animateTorricelli();

    // ===== SCENARIO 4: MAGNUS EFFECT =====
    const magnusCanvas = document.getElementById('magnusCanvas');
    const magnusCtx = magnusCanvas.getContext('2d');
    let magnusAnimFrame = 0;

    const magnusInputs = {
      V: document.getElementById('magnusV'),
      RPM: document.getElementById('magnusRPM'),
      R: document.getElementById('magnusR'),
      dir: document.getElementById('magnusDir')
    };

    function drawMagnus() {
      const V = parseFloat(magnusInputs.V.value);
      const RPM = parseFloat(magnusInputs.RPM.value);
      const R = parseFloat(magnusInputs.R.value) / 100;
      const dir = magnusInputs.dir.value;

      const omega = (RPM / 60) * 2 * Math.PI;
      const rho = 1.225;
      const S = Math.PI * R * R;

      // Magnus force coefficient (simplified)
      const spinRatio = (omega * R) / V;
      let FM = 0.5 * rho * V * V * S * spinRatio;

      if (V === 0) FM = 0;

      // Flight calculations
      const flightTime = 18.3 / V;
      const deflection = FM * flightTime * flightTime / 2 / 1;

      // Update stats
      document.getElementById('magnusVVal').textContent = V;
      document.getElementById('magnusRPMVal').textContent = RPM;
      document.getElementById('magnusRVal').textContent = R.toFixed(2);
      document.getElementById('magnusStatOmega').textContent = omega.toFixed(1) + ' rad/s';
      document.getElementById('magnusStatForce').textContent = FM.toFixed(1) + ' N';
      document.getElementById('magnusStatDef').textContent = (deflection / 10).toFixed(2) + ' m';
      document.getElementById('magnusStatTime').textContent = flightTime.toFixed(2) + ' s';

      // Clear canvas
      magnusCtx.fillStyle = '#0a0a0a';
      magnusCtx.fillRect(0, 0, magnusCanvas.width, magnusCanvas.height);

      const startX = 50;
      const startY = magnusCanvas.height / 2;
      const endX = magnusCanvas.width - 50;

      // Draw field
      magnusCtx.strokeStyle = '#555555';
      magnusCtx.lineWidth = 1;
      magnusCtx.setLineDash([5, 5]);
      for (let y = 50; y < magnusCanvas.height; y += 50) {
        magnusCtx.beginPath();
        magnusCtx.moveTo(startX, y);
        magnusCtx.lineTo(endX, y);
        magnusCtx.stroke();
      }
      magnusCtx.setLineDash([]);

      // Calculate ball trajectory
      const positions = [];
      const steps = 50;
      let deflectionDir = 0;

      if (dir === 'topspin') deflectionDir = 0.8;
      else if (dir === 'backspin') deflectionDir = -0.8;
      else if (dir === 'sidespin') deflectionDir = (Math.random() > 0.5 ? 1 : -1) * 0.6;

      for (let i = 0; i <= steps; i++) {
        const t = i / steps;
        const x = startX + t * (endX - startX);
        const baseLine = startY;

        // Parabolic trajectory with Magnus deflection
        const gravity = 10;
        const y = baseLine + (gravity * (t ** 2) * 50) + (deflectionDir * t * (endX - startX) * 0.3);

        positions.push({ x, y, t });
      }

      // Draw trajectory
      magnusCtx.strokeStyle = '#00c896';
      magnusCtx.lineWidth = 2;
      magnusCtx.beginPath();
      positions.forEach((p, i) => {
        if (i === 0) magnusCtx.moveTo(p.x, p.y);
        else magnusCtx.lineTo(p.x, p.y);
      });
      magnusCtx.stroke();

      // Draw current ball position
      const ballProgress = (magnusAnimFrame % 100) / 100;
      const ballIdx = Math.floor(ballProgress * positions.length);
      const ballPos = positions[Math.min(ballIdx, positions.length - 1)];

      // Draw ball
      magnusCtx.fillStyle = '#e8e0d5';
      magnusCtx.beginPath();
      magnusCtx.arc(ballPos.x, ballPos.y, 8, 0, Math.PI * 2);
      magnusCtx.fill();

      // Draw spin direction on ball
      magnusCtx.strokeStyle = '#ff2200';
      magnusCtx.lineWidth = 2;
      const spinAngle = (magnusAnimFrame / 30) * Math.PI;

      if (dir !== 'none') {
        for (let i = 0; i < 3; i++) {
          const angle = spinAngle + (i * Math.PI / 3);
          const x1 = ballPos.x + Math.cos(angle) * 6;
          const y1 = ballPos.y + Math.sin(angle) * 6;
          magnusCtx.beginPath();
          magnusCtx.arc(x1, y1, 2, 0, Math.PI * 2);
          magnusCtx.fill();
        }
      }

      // Draw streamlines
      const leftSide = deflectionDir > 0 ? 'slower' : 'faster';
      const rightSide = deflectionDir > 0 ? 'faster' : 'slower';

      for (let s = 0; s < 5; s++) {
        const offsetY = -60 + s * 30;
        const streamY = ballPos.y + offsetY;

        // Left side (slower)
        magnusCtx.strokeStyle = deflectionDir > 0 ? 'rgba(68, 136, 255, 0.4)' : 'rgba(255, 34, 0, 0.4)';
        magnusCtx.lineWidth = 1.5;
        magnusCtx.beginPath();
        for (let x = startX; x < ballPos.x; x += 15) {
          magnusCtx.lineTo(x, streamY - Math.sin(x / 20 + magnusAnimFrame / 20) * 5);
        }
        magnusCtx.stroke();

        // Right side (faster)
        magnusCtx.strokeStyle = deflectionDir > 0 ? 'rgba(255, 34, 0, 0.4)' : 'rgba(68, 136, 255, 0.4)';
        magnusCtx.beginPath();
        for (let x = ballPos.x; x < endX; x += 15) {
          magnusCtx.lineTo(x, streamY + Math.sin(x / 20 + magnusAnimFrame / 20) * 5);
        }
        magnusCtx.stroke();
      }

      // Labels
      magnusCtx.fillStyle = '#555555';
      magnusCtx.font = '11px DM Mono';
      magnusCtx.textAlign = 'center';

      if (dir === 'topspin') {
        magnusCtx.fillText('Topspin ↻ (Dips)', ballPos.x, 30);
        magnusCtx.fillText('Faster above', ballPos.x - 80, ballPos.y - 40);
        magnusCtx.fillText('Slower below', ballPos.x - 80, ballPos.y + 40);
      } else if (dir === 'backspin') {
        magnusCtx.fillText('Backspin ↺ (Rises)', ballPos.x, 30);
        magnusCtx.fillText('Slower above', ballPos.x - 80, ballPos.y - 40);
        magnusCtx.fillText('Faster below', ballPos.x - 80, ballPos.y + 40);
      } else {
        magnusCtx.fillText('Sidespin ⤻ (Curves)', ballPos.x, 30);
      }

      // Force arrow
      if (FM > 1) {
        const arrowLen = Math.min(100, FM * 5);
        magnusCtx.strokeStyle = '#ff2200';
        magnusCtx.lineWidth = 3;
        magnusCtx.beginPath();
        magnusCtx.moveTo(ballPos.x, ballPos.y);
        magnusCtx.lineTo(ballPos.x, ballPos.y - arrowLen * (deflectionDir > 0 ? 1 : -1));
        magnusCtx.stroke();
      }

      magnusAnimFrame++;
    }

    Object.values(magnusInputs).forEach(input => {
      input.addEventListener('input', () => {});
    });

    function animateMagnus() {
      drawMagnus();
      requestAnimationFrame(animateMagnus);
    }
    animateMagnus();