physics · 3d
DOPPLER EFFECT
Watch wave fronts compress and stretch as a source moves. From blueshift to sonic booms — all in real time.
Source at rest. Wave fronts form perfect concentric circles. Observed = source frequency.
Source freq440 Hz
Observed440 Hz
Shift—
Mach №0.00
Waves0
1× speed
Source
Observer
Blueshift (higher f)
Redshift (lower f)
No shift
A — Theoretical - - - source reference | ━━ observed (Doppler formula, instant)
B — Measured from wave arrivals ━━ observed | │ tick = ring hits observer (has travel-time lag; shows real transitions)
The Doppler Formula
fobs = fsrc × vsound / (vsound − vsrc · cos θ)
θ = angle between source velocity and direction toward observer.
When source approaches (θ≈0°): denominator shrinks → blueshift (higher pitch).
When source recedes (θ≈180°): denominator grows → redshift (lower pitch).
At Mach 1 the denominator → 0, fobs → ∞ — wave fronts pile up at source.
Beyond Mach 1 a Mach cone forms with half-angle α = arcsin(1/M).
The ring color encodes the Doppler shift each segment of a wave front would carry: blue half faces the direction of motion (compressed), red half trails behind (stretched).
The oscilloscope draws the waveform using the instantaneous Doppler formula evaluated at each frame — it's a theoretical prediction, not measured from individual wave-ring arrivals. For a more accurate capture you'd track each ring reaching the observer and measure inter-arrival periods; the formula gives the same steady-state result once the source is moving at constant velocity.
When source approaches (θ≈0°): denominator shrinks → blueshift (higher pitch).
When source recedes (θ≈180°): denominator grows → redshift (lower pitch).
At Mach 1 the denominator → 0, fobs → ∞ — wave fronts pile up at source.
Beyond Mach 1 a Mach cone forms with half-angle α = arcsin(1/M).
The ring color encodes the Doppler shift each segment of a wave front would carry: blue half faces the direction of motion (compressed), red half trails behind (stretched).
The oscilloscope draws the waveform using the instantaneous Doppler formula evaluated at each frame — it's a theoretical prediction, not measured from individual wave-ring arrivals. For a more accurate capture you'd track each ring reaching the observer and measure inter-arrival periods; the formula gives the same steady-state result once the source is moving at constant velocity.
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────
const C = 100; // speed of sound (visual units/sec)
const MAX_R = 90; // max wave radius before culling
const RING_SEGS = 96; // segments per wave ring
// ─────────────────────────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────────────────────────
let paused = false;
let simSpeed = 1;
let simTime = 0;
let lastEmit = -99;
let waves = [];
let oscTime = 0;
let srcFreq = 440;
let srcVel = new THREE.Vector3(0, 0, 0);
let srcPos = new THREE.Vector3(0, 0, 0);
let obsPos = new THREE.Vector3(14, 0, 0);
let currentCase = 0;
// Computed each frame
let fObs = 440;
// ── Signal buffer (wave-arrival based waveform) ──────────────────────────────
const SIG_RATE = 240; // samples per sim-second
const SIG_BUF = 720; // 3 seconds at SIG_RATE
let sigBuf = new Float32Array(SIG_BUF);
let sigHead = 0; // next absolute write index
let sigPhase = 0; // continuous phase accumulator
let sigPeriod = 0.2; // inter-arrival period (seconds), default 5 Hz
let sigLastArrival = -1; // simTime of last ring arrival
let sigArrivalTimes = []; // rolling list of arrival simTimes
let sigSampleAccum = 0; // fractional sample accumulator
// ─────────────────────────────────────────────────────────────────────────────
// THREE.JS SETUP
// ─────────────────────────────────────────────────────────────────────────────
const threeCanvas = document.getElementById('three-canvas');
const renderer = new THREE.WebGLRenderer({ canvas: threeCanvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x050508, 1);
renderer.shadowMap.enabled = false;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x050508, 0.008);
const camera = new THREE.PerspectiveCamera(52, 2, 0.1, 600);
camera.position.set(0, 42, 32);
camera.lookAt(0, 0, 0);
// Resize handling
function resizeRenderer() {
const w = threeCanvas.clientWidth;
const h = threeCanvas.clientHeight;
if (renderer.domElement.width !== w || renderer.domElement.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// STARS
// ─────────────────────────────────────────────────────────────────────────────
(function buildStars() {
const count = 1800;
const pos = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) pos[i] = (Math.random() - 0.5) * 600;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
color: 0xffffff, size: 0.28, transparent: true, opacity: 0.55
})));
})();
// ─────────────────────────────────────────────────────────────────────────────
// GRID
// ─────────────────────────────────────────────────────────────────────────────
const grid = new THREE.GridHelper(160, 60, 0x181820, 0x131318);
grid.position.y = -0.05;
scene.add(grid);
// Path line along X
(function buildPath() {
const pts = [new THREE.Vector3(-50, 0, 0), new THREE.Vector3(50, 0, 0)];
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineDashedMaterial({ color: 0x2a2a3a, dashSize: 1.5, gapSize: 1 })
));
})();
// ─────────────────────────────────────────────────────────────────────────────
// SOURCE SPHERE
// ─────────────────────────────────────────────────────────────────────────────
const srcMat = new THREE.MeshStandardMaterial({
color: 0xff6600, emissive: 0xff3300, emissiveIntensity: 2.2, roughness: 0.3, metalness: 0.5
});
const srcMesh = new THREE.Mesh(new THREE.SphereGeometry(0.75, 20, 20), srcMat);
scene.add(srcMesh);
// glow halo
const srcHaloMat = new THREE.MeshBasicMaterial({
color: 0xff4400, transparent: true, opacity: 0.12, side: THREE.BackSide
});
srcMesh.add(new THREE.Mesh(new THREE.SphereGeometry(1.4, 16, 16), srcHaloMat));
// point light that follows source
const srcLight = new THREE.PointLight(0xff5500, 3, 25);
scene.add(srcLight);
// ─────────────────────────────────────────────────────────────────────────────
// OBSERVER SPHERE
// ─────────────────────────────────────────────────────────────────────────────
const obsMat = new THREE.MeshStandardMaterial({
color: 0x00c8ff, emissive: 0x007acc, emissiveIntensity: 1.8, roughness: 0.3, metalness: 0.5
});
const obsMesh = new THREE.Mesh(new THREE.SphereGeometry(0.6, 20, 20), obsMat);
scene.add(obsMesh);
const obsHaloMat = new THREE.MeshBasicMaterial({
color: 0x00aaff, transparent: true, opacity: 0.1, side: THREE.BackSide
});
obsMesh.add(new THREE.Mesh(new THREE.SphereGeometry(1.1, 16, 16), obsHaloMat));
const obsLight = new THREE.PointLight(0x0088ff, 1.5, 18);
obsMesh.add(obsLight);
// ambient
scene.add(new THREE.AmbientLight(0xffffff, 0.08));
// ─────────────────────────────────────────────────────────────────────────────
// TRAIL SYSTEM
// ─────────────────────────────────────────────────────────────────────────────
const TRAIL_LEN = 120;
const trailPos = new Float32Array(TRAIL_LEN * 3);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
const trailPts = new THREE.Points(trailGeo, new THREE.PointsMaterial({
color: 0xff5500, size: 0.22, transparent: true, opacity: 0.35,
blending: THREE.AdditiveBlending, depthWrite: false
}));
scene.add(trailPts);
let trailBuf = [];
function updateTrail() {
trailBuf.unshift({ x: srcPos.x, y: srcPos.y, z: srcPos.z });
if (trailBuf.length > TRAIL_LEN) trailBuf.pop();
for (let i = 0; i < TRAIL_LEN; i++) {
if (i < trailBuf.length) {
trailPos[i*3] = trailBuf[i].x;
trailPos[i*3+1] = trailBuf[i].y;
trailPos[i*3+2] = trailBuf[i].z;
} else {
trailPos[i*3+1] = -999;
}
}
trailGeo.attributes.position.needsUpdate = true;
}
// ─────────────────────────────────────────────────────────────────────────────
// WAVE RINGS — per-vertex coloring
// ─────────────────────────────────────────────────────────────────────────────
function makeWaveRing(origin, velAtEmit) {
const pts = [];
const cols = [];
const speed = velAtEmit.length();
const mach = speed / C;
const vDir = speed > 0.001 ? velAtEmit.clone().normalize() : new THREE.Vector3();
for (let i = 0; i <= RING_SEGS; i++) {
const a = (i / RING_SEGS) * Math.PI * 2;
pts.push(new THREE.Vector3(Math.cos(a), 0, Math.sin(a)));
// outward direction at this point on unit circle
const dot = vDir.x * Math.cos(a) + vDir.z * Math.sin(a);
const t = Math.min(Math.abs(dot) * mach * 1.4, 1);
let r, g, b;
if (dot > 0) { // forward of motion → blueshift
r = 0.3 + (1-t)*0.5; g = 0.4 + (1-t)*0.5; b = 1.0;
} else if (dot < 0) { // trailing → redshift
r = 1.0; g = 0.3 + (1-t)*0.5; b = 0.2 + (1-t)*0.5;
} else { // perpendicular → neutral green
r = 0.4; g = 1.0; b = 0.5;
}
cols.push(r, g, b);
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(cols), 3));
const mat = new THREE.LineBasicMaterial({
vertexColors: true, transparent: true, opacity: 0.82,
blending: THREE.AdditiveBlending, depthWrite: false
});
const ring = new THREE.LineLoop(geo, mat);
ring.position.copy(origin);
ring.position.y = 0;
scene.add(ring);
return { ring, origin: origin.clone(), radius: 0.1, observed: false };
}
function clearWaves() {
for (const w of waves) {
scene.remove(w.ring);
w.ring.geometry.dispose();
w.ring.material.dispose();
}
waves = [];
// Reset arrival signal
sigBuf.fill(0);
sigHead = 0; sigPhase = 0;
sigPeriod = 1 / Math.max(1, srcFreq / 88);
sigLastArrival = -1; sigArrivalTimes = []; sigSampleAccum = 0;
}
function updateWaves(dt) {
const toRemove = [];
for (let i = 0; i < waves.length; i++) {
const w = waves[i];
w.radius += C * dt;
w.ring.scale.setScalar(w.radius);
// Observer hit check
if (!w.observed) {
const dx = obsPos.x - w.origin.x;
const dz = obsPos.z - w.origin.z;
if (w.radius >= Math.sqrt(dx*dx + dz*dz)) {
w.observed = true;
// Update signal period from inter-arrival time
if (sigLastArrival >= 0) {
sigPeriod = Math.max(0.01, simTime - sigLastArrival);
}
sigLastArrival = simTime;
sigArrivalTimes.push(simTime);
// Trim arrivals that have scrolled off the visible window
const winSec = SIG_BUF / SIG_RATE;
while (sigArrivalTimes.length > 0 && sigArrivalTimes[0] < simTime - winSec - 0.5) {
sigArrivalTimes.shift();
}
// flash observer
obsMat.emissiveIntensity = 4;
setTimeout(() => { obsMat.emissiveIntensity = 1.8; }, 120);
}
}
// fade out
const fadeAt = MAX_R * 0.55;
if (w.radius > fadeAt) {
w.ring.material.opacity = 0.82 * Math.max(0, 1 - (w.radius - fadeAt) / (MAX_R - fadeAt));
}
if (w.radius > MAX_R) {
scene.remove(w.ring);
w.ring.geometry.dispose();
w.ring.material.dispose();
toRemove.push(i);
}
}
for (let i = toRemove.length - 1; i >= 0; i--) waves.splice(toRemove[i], 1);
}
// ─────────────────────────────────────────────────────────────────────────────
// DOPPLER FORMULA
// ─────────────────────────────────────────────────────────────────────────────
function computeDopplerFreq() {
const dx = obsPos.x - srcPos.x;
const dz = obsPos.z - srcPos.z;
const d = Math.sqrt(dx*dx + dz*dz);
if (d < 0.001) return srcFreq;
const cosT = (srcVel.x * dx + srcVel.z * dz) / (d * C); // (v_src/C) · cosθ
const denom = 1 - cosT;
if (Math.abs(denom) < 0.005) return 9999;
return Math.max(0.5, Math.min(9999, srcFreq / denom));
}
// ─────────────────────────────────────────────────────────────────────────────
// CASES
// ─────────────────────────────────────────────────────────────────────────────
const CASES = [
{
label: 'Stationary',
desc: 'Source at rest. Wave fronts form perfect concentric circles. Observed = source frequency.',
setupFn() {
srcPos.set(0, 0, 0); srcVel.set(0, 0, 0); obsPos.set(14, 0, 0);
},
tickFn(_dt) {}
},
{
label: 'Approaching',
desc: 'Source moves toward observer (Mach 0.5). Waves bunch up → higher pitch (blueshift).',
setupFn() {
srcPos.set(-32, 0, 0); srcVel.set(C * 0.5, 0, 0); obsPos.set(14, 0, 0);
},
tickFn(dt) {
srcPos.addScaledVector(srcVel, dt);
if (srcPos.x > 46) { srcPos.set(-32, 0, 0); clearWaves(); trailBuf = []; }
}
},
{
label: 'Receding',
desc: 'Source moves away from observer (Mach 0.5). Waves stretch out → lower pitch (redshift).',
setupFn() {
srcPos.set(14, 0, 0); srcVel.set(-C * 0.5, 0, 0); obsPos.set(14, 0, 0);
},
tickFn(dt) {
srcPos.addScaledVector(srcVel, dt);
if (srcPos.x < -46) { srcPos.set(14, 0, 0); clearWaves(); trailBuf = []; }
}
},
{
label: 'Supersonic',
desc: 'Mach 1.5 — source outruns its own waves. Rings pile into a Mach cone. Sonic boom!',
setupFn() {
srcPos.set(-40, 0, 0); srcVel.set(C * 1.5, 0, 0); obsPos.set(0, 0, 14);
},
tickFn(dt) {
srcPos.addScaledVector(srcVel, dt);
if (srcPos.x > 50) { srcPos.set(-40, 0, 0); clearWaves(); trailBuf = []; }
}
},
{
label: 'Custom',
desc: 'Free mode — tweak velocity, frequency, and observer position. Source loops at whatever speed you set.',
setupFn() {
// Preserve current srcVel; pick a sensible starting position
if (srcVel.x > 0) srcPos.set(-40, 0, 0);
else if (srcVel.x < 0) srcPos.set(40, 0, 0);
else srcPos.set(0, 0, 0);
},
tickFn(dt) {
if (srcVel.lengthSq() < 0.001) return; // truly stationary — don't loop
srcPos.addScaledVector(srcVel, dt);
if (srcVel.x > 0 && srcPos.x > 52) { srcPos.set(-45, 0, 0); clearWaves(); trailBuf = []; }
if (srcVel.x < 0 && srcPos.x < -52) { srcPos.set( 45, 0, 0); clearWaves(); trailBuf = []; }
}
}
];
// ─────────────────────────────────────────────────────────────────────────────
// OSCILLOSCOPE
// ─────────────────────────────────────────────────────────────────────────────
const oscCanvas = document.getElementById('osc-canvas');
const oscCtx = oscCanvas.getContext('2d');
function resizeOsc() {
const r = oscCanvas.getBoundingClientRect();
oscCanvas.width = Math.round(r.width);
oscCanvas.height = Math.round(r.height);
}
function drawOscilloscope() {
const W = oscCanvas.width, H = oscCanvas.height;
if (W === 0 || H === 0) return;
// background
oscCtx.fillStyle = '#06090a';
oscCtx.fillRect(0, 0, W, H);
// grid
oscCtx.strokeStyle = 'rgba(255,255,255,0.04)';
oscCtx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
oscCtx.beginPath(); oscCtx.moveTo(i*(W/4), 0); oscCtx.lineTo(i*(W/4), H); oscCtx.stroke();
}
oscCtx.beginPath(); oscCtx.moveTo(0, H/2); oscCtx.lineTo(W, H/2);
oscCtx.strokeStyle = 'rgba(255,255,255,0.07)'; oscCtx.stroke();
// ratio uses raw fObs so color is correct even when f_obs < slider minimum
const ratio = fObs / srcFreq;
const fClamped = Math.max(0.5, Math.min(3000, fObs));
const timeWin = 3 / srcFreq;
const amp = H * 0.33;
const N = 300; // fixed sample count for smooth curves
// Source reference (dashed gray)
oscCtx.setLineDash([4, 4]);
oscCtx.strokeStyle = '#444';
oscCtx.lineWidth = 1;
oscCtx.beginPath();
for (let i = 0; i <= N; i++) {
const xn = i / N;
const x = xn * W;
const t = (xn) * timeWin;
const y = H/2 - Math.sin(2 * Math.PI * srcFreq * (oscTime - timeWin + t)) * amp;
i === 0 ? oscCtx.moveTo(x, y) : oscCtx.lineTo(x, y);
}
oscCtx.stroke();
oscCtx.setLineDash([]);
// Observed wave (solid, colored — ratio drives color, fClamped drives drawing)
let waveColor;
if (ratio > 1.05) waveColor = '#4488ff';
else if (ratio < 0.95) waveColor = '#ff4422';
else waveColor = '#00c896';
// update oscilloscope legend
document.getElementById('osc-legend').style.color = waveColor;
oscCtx.strokeStyle = waveColor;
oscCtx.lineWidth = 2;
oscCtx.beginPath();
for (let i = 0; i <= N; i++) {
const xn = i / N;
const x = xn * W;
const t = (xn) * timeWin;
const y = H/2 - Math.sin(2 * Math.PI * fClamped * (oscTime - timeWin + t)) * amp;
i === 0 ? oscCtx.moveTo(x, y) : oscCtx.lineTo(x, y);
}
oscCtx.stroke();
}
// ─────────────────────────────────────────────────────────────────────────────
// ARRIVAL-BASED WAVEFORM
// ─────────────────────────────────────────────────────────────────────────────
const arrCanvas = document.getElementById('arr-canvas');
const arrCtx = arrCanvas.getContext('2d');
function resizeArr() {
const r = arrCanvas.getBoundingClientRect();
arrCanvas.width = Math.round(r.width);
arrCanvas.height = Math.round(r.height);
}
function drawArrivalWaveform() {
const W = arrCanvas.width, H = arrCanvas.height;
if (W === 0 || H === 0) return;
const amp = H * 0.38;
// background
arrCtx.fillStyle = '#04080a';
arrCtx.fillRect(0, 0, W, H);
// grid
arrCtx.strokeStyle = 'rgba(255,255,255,0.04)';
arrCtx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
arrCtx.beginPath(); arrCtx.moveTo(i*(W/4), 0); arrCtx.lineTo(i*(W/4), H); arrCtx.stroke();
}
arrCtx.beginPath(); arrCtx.moveTo(0, H/2); arrCtx.lineTo(W, H/2);
arrCtx.strokeStyle = 'rgba(255,255,255,0.07)'; arrCtx.stroke();
const count = Math.min(sigHead, SIG_BUF);
if (count < 4) {
arrCtx.fillStyle = 'rgba(255,255,255,0.18)';
arrCtx.font = '11px DM Mono, monospace';
arrCtx.textAlign = 'center';
arrCtx.fillText('Waiting for first wave arrivals…', W / 2, H / 2 + 4);
return;
}
// Color based on measured period vs source emit period
const srcEmitPeriod = 88 / srcFreq; // actual visual ring period
const measRatio = srcEmitPeriod / Math.max(sigPeriod, 0.001); // f_obs / f_src
let arrColor;
if (measRatio > 1.05) arrColor = '#4488ff';
else if (measRatio < 0.95) arrColor = '#ff4422';
else arrColor = '#00c896';
document.getElementById('arr-legend').style.color = arrColor;
// Draw waveform (oldest sample left, newest right)
arrCtx.strokeStyle = arrColor;
arrCtx.lineWidth = 2;
arrCtx.beginPath();
for (let i = 0; i < count; i++) {
const bufIdx = ((sigHead - count + i) % SIG_BUF + SIG_BUF) % SIG_BUF;
const x = (i / (count - 1)) * W;
const y = H / 2 - sigBuf[bufIdx] * amp;
i === 0 ? arrCtx.moveTo(x, y) : arrCtx.lineTo(x, y);
}
arrCtx.stroke();
// Arrival tick marks at bottom edge
const winSec = count / SIG_RATE;
const winStart = simTime - winSec;
arrCtx.strokeStyle = 'rgba(255,255,255,0.55)';
arrCtx.lineWidth = 1;
for (const t of sigArrivalTimes) {
const x = W * (t - winStart) / winSec;
if (x >= 0 && x <= W) {
arrCtx.beginPath();
arrCtx.moveTo(x, H - 10);
arrCtx.lineTo(x, H);
arrCtx.stroke();
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// HUD UPDATE
// ─────────────────────────────────────────────────────────────────────────────
function updateHUD() {
const mach = srcVel.length() / C;
const shift = ((fObs / srcFreq) - 1) * 100;
document.getElementById('h-fsrc').textContent = srcFreq + ' Hz';
document.getElementById('h-fobs').textContent = fObs >= 9999 ? '∞' : Math.round(fObs) + ' Hz';
document.getElementById('h-mach').textContent = mach.toFixed(2);
document.getElementById('h-waves').textContent = waves.length;
const shiftEl = document.getElementById('h-shift');
const fobsEl = document.getElementById('h-fobs');
if (fObs >= 9999) {
shiftEl.textContent = '∞';
shiftEl.className = 'hv-val hv-warn';
fobsEl.className = 'hv-val hv-warn';
} else if (Math.abs(shift) < 2) {
shiftEl.textContent = '—';
shiftEl.className = 'hv-val hv-green';
fobsEl.className = 'hv-val hv-green';
} else if (shift > 0) {
shiftEl.textContent = '+' + shift.toFixed(1) + '% ↑';
shiftEl.className = 'hv-val hv-blue';
fobsEl.className = 'hv-val hv-blue';
} else {
shiftEl.textContent = shift.toFixed(1) + '% ↓';
shiftEl.className = 'hv-val hv-red';
fobsEl.className = 'hv-val hv-red';
}
// Mach colour
const machEl = document.getElementById('h-mach');
machEl.className = mach >= 1 ? 'hv-val hv-warn' : 'hv-val';
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN SIM TICK
// ─────────────────────────────────────────────────────────────────────────────
function tick(dt) {
simTime += dt;
oscTime += dt;
CASES[currentCase].tickFn(dt);
// emit waves
const period = 1 / (srcFreq / 88); // visual emit rate ≈ 5 Hz at 440 Hz
if (simTime - lastEmit >= period) {
waves.push(makeWaveRing(srcPos.clone(), srcVel.clone()));
lastEmit = simTime;
// pulse source glow
srcMat.emissiveIntensity = 5;
setTimeout(() => { srcMat.emissiveIntensity = 2.2; }, 80);
}
updateWaves(dt);
updateTrail();
// Sample signal buffer (phase driven by inter-arrival period)
sigPhase += dt / Math.max(sigPeriod, 0.001);
sigSampleAccum += dt;
const sampleInterval = 1 / SIG_RATE;
while (sigSampleAccum >= sampleInterval) {
sigSampleAccum -= sampleInterval;
sigBuf[sigHead % SIG_BUF] = Math.sin(2 * Math.PI * sigPhase);
sigHead++;
}
// sync meshes
srcMesh.position.copy(srcPos);
obsMesh.position.copy(obsPos);
srcLight.position.copy(srcPos).y += 2;
// subtle source pulse scale
srcMesh.scale.setScalar(1 + 0.08 * Math.sin(simTime * (srcFreq / 88) * Math.PI * 2));
fObs = computeDopplerFreq();
updateHUD();
}
// ─────────────────────────────────────────────────────────────────────────────
// CONTROLS
// ─────────────────────────────────────────────────────────────────────────────
function setCase(idx) {
currentCase = idx;
clearWaves(); trailBuf = [];
simTime = 0; lastEmit = -99; oscTime = 0;
CASES[idx].setupFn();
fObs = srcFreq;
// sync sliders
const mach = srcVel.length() / C;
document.getElementById('rng-vel').value = mach;
document.getElementById('lbl-vel').textContent = mach.toFixed(2) + ' Mach';
document.getElementById('rng-obs').value = obsPos.x;
document.getElementById('lbl-obs').textContent = 'x = ' + (obsPos.x >= 0 ? '+' : '') + obsPos.x;
document.getElementById('case-desc').textContent = CASES[idx].desc;
document.querySelectorAll('.case-btn').forEach((b, i) => b.classList.toggle('active', i === idx));
updateHUD();
}
function togglePlay() {
paused = !paused;
document.getElementById('btn-play').textContent = paused ? '▶ Play' : '⏸ Pause';
}
function setSpeed(s) {
simSpeed = s;
document.getElementById('spd-label').textContent = s + '× speed';
document.querySelectorAll('.spd-btn').forEach(b => {
b.classList.toggle('active', parseFloat(b.textContent) === s);
});
}
function resetSim() { setCase(currentCase); }
// Silently activates Custom mode without resetting position/velocity —
// used when the user tweaks a slider while on a preset that doesn't move the source.
function activateCustom() {
currentCase = 4;
document.querySelectorAll('.case-btn').forEach((b, i) => b.classList.toggle('active', i === 4));
document.getElementById('case-desc').textContent = CASES[4].desc;
}
function onVelChange(v) {
v = parseFloat(v);
document.getElementById('lbl-vel').textContent = v.toFixed(2) + ' Mach';
// Case 2 (Receding) moves leftward; everything else moves rightward
const dir = currentCase === 2 ? -1 : 1;
srcVel.set(C * v * dir, 0, 0);
// If on Stationary (Case 0), user clearly wants motion — switch to Custom so the source actually moves
if (currentCase === 0 && v > 0) activateCustom();
}
function onFreqChange(v) {
srcFreq = parseInt(v);
document.getElementById('lbl-freq').textContent = v + ' Hz';
}
function onObsChange(v) {
v = parseFloat(v);
obsPos.x = v;
document.getElementById('lbl-obs').textContent = 'x = ' + (v >= 0 ? '+' : '') + v;
obsMesh.position.copy(obsPos);
}
// ─────────────────────────────────────────────────────────────────────────────
// SLOW CAMERA DRIFT
// ─────────────────────────────────────────────────────────────────────────────
let camAngle = 0;
function driftCamera(dt) {
camAngle += dt * 0.012;
camera.position.x = Math.sin(camAngle) * 5;
camera.lookAt(0, 0, 0);
}
// ─────────────────────────────────────────────────────────────────────────────
// ANIMATION LOOP
// ─────────────────────────────────────────────────────────────────────────────
let lastTs = null;
function animate(ts) {
requestAnimationFrame(animate);
if (lastTs === null) { lastTs = ts; return; }
let dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
resizeRenderer();
if (!paused) {
tick(dt * simSpeed);
driftCamera(dt);
drawOscilloscope();
drawArrivalWaveform();
}
renderer.render(scene, camera);
}
// ─────────────────────────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────────────────────────
window.addEventListener('resize', () => { resizeRenderer(); resizeOsc(); resizeArr(); });
resizeOsc(); resizeArr();
setCase(0);
animate(0);
// expose handlers for inline HTML
window.setCase = setCase;
window.togglePlay = togglePlay;
window.setSpeed = setSpeed;
window.resetSim = resetSim;
window.onVelChange = onVelChange;
window.onFreqChange = onFreqChange;
window.onObsChange = onObsChange;