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₂
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
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
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
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();