Wave Superposition App — ChatGPT - Conversation Transcript

conceptual models/apps

Id like to build a web app to help students understand wave interactions/superposition. The student should be able to select between 1–5 sine waves. For each wave they can choose the wavelength, amplitude and speed. Then the app would show the animated individual waves (translucent in the background) with the sum of the waves (solid in the foreground). Without doing any coding: does this make sense? Is there anything else I should be including?

Yes, it makes sense — it’s a clear, educational setup. You might also include:

  • A pause/play and reset option.
  • A time slider to view snapshots.
  • Option to toggle visibility of individual waves.
  • Maybe a preset examples dropdown (e.g. constructive, destructive, beats).

Otherwise, your concept is solid.

Can we start with a single standalone web app in HTML/JS that has an input box for a single wave that controls wavelength, speed, amplitude and phase, using sliders. Plus a display window that renders the moving wave?

Yes — that’s a good starting point. Begin with one sine wave controlled by sliders for wavelength, amplitude, speed, and phase, rendered in an animated canvas. Once that’s working smoothly, it’ll be easy to expand to multiple waves later.

Please provide the code

Here’s a minimal standalone HTML/JS app for a single animated sine wave with sliders for wavelength, amplitude, speed, and phase:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sine Wave Visualizer</title>
<style>
  body { font-family: sans-serif; text-align: center; background: #f5f5f5; }
  canvas { border: 1px solid #ccc; background: white; margin-top: 10px; }
  .controls { margin: 10px auto; width: 300px; text-align: left; }
  label { display: block; margin: 6px 0 2px; }
</style>
</head>
<body>
<h2>Single Wave Visualizer</h2>

<div class="controls">
  <label>Wavelength: <span id="wlVal">100</span></label>
  <input type="range" id="wavelength" min="20" max="300" value="100">
  
  <label>Amplitude: <span id="ampVal">50</span></label>
  <input type="range" id="amplitude" min="10" max="100" value="50">
  
  <label>Speed: <span id="spdVal">0.05</span></label>
  <input type="range" id="speed" min="0.01" max="0.2" step="0.01" value="0.05">
  
  <label>Phase: <span id="phVal">0</span></label>
  <input type="range" id="phase" min="0" max="6.28" step="0.1" value="0">
</div>

<canvas id="waveCanvas" width="600" height="300"></canvas>

<script>
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');

let wavelength = 100, amplitude = 50, speed = 0.05, phase = 0, t = 0;

const controls = ['wavelength','amplitude','speed','phase'];
controls.forEach(id => {
  document.getElementById(id).oninput = e => {
    window[id] = parseFloat(e.target.value);
    document.getElementById(id.slice(0,2)+'Val').textContent = e.target.value;
  };
});

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  for (let x = 0; x < canvas.width; x++) {
    const y = canvas.height/2 + amplitude * Math.sin((2*Math.PI/wavelength)*x - speed*t + phase);
    x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.strokeStyle = '#0077cc';
  ctx.lineWidth = 2;
  ctx.stroke();
  t += 1;
  requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>
Open page in browser

You can open this as a .html file — the sliders will adjust the wave in real time.

The sliders dont seem to be changing anything, can you try again. Also Id like speeds that could be positive or negative

Here’s a corrected version — sliders now update live, and speed can be positive or negative:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sine Wave Visualizer</title>
<style>
  body { font-family: sans-serif; text-align: center; background: #f5f5f5; }
  canvas { border: 1px solid #ccc; background: white; margin-top: 10px; }
  .controls { margin: 10px auto; width: 320px; text-align: left; }
  label { display: block; margin: 6px 0 2px; }
</style>
</head>
<body>
<h2>Single Wave Visualizer</h2>

<div class="controls">
  <label>Wavelength: <span id="wlVal">100</span></label>
  <input type="range" id="wavelength" min="20" max="300" value="100">

  <label>Amplitude: <span id="ampVal">50</span></label>
  <input type="range" id="amplitude" min="10" max="100" value="50">

  <label>Speed: <span id="spdVal">0.05</span></label>
  <input type="range" id="speed" min="-0.2" max="0.2" step="0.01" value="0.05">

  <label>Phase: <span id="phVal">0</span></label>
  <input type="range" id="phase" min="0" max="6.28" step="0.1" value="0">
</div>

<canvas id="waveCanvas" width="600" height="300"></canvas>

<script>
const canvas = document.getElementById('waveCanvas');
const ctx = canvas.getContext('2d');

let params = {
  wavelength: 100,
  amplitude: 50,
  speed: 0.05,
  phase: 0
};

for (let key in params) {
  const slider = document.getElementById(key);
  const label = document.getElementById(key.slice(0,2) + 'Val');
  slider.addEventListener('input', e => {
    params[key] = parseFloat(e.target.value);
    label.textContent = e.target.value;
  });
}

let t = 0;
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.beginPath();
  for (let x = 0; x < canvas.width; x++) {
    const y = canvas.height/2 + params.amplitude *
      Math.sin((2 * Math.PI / params.wavelength) * x - params.phase - params.speed * t);
    x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.strokeStyle = '#0077cc';
  ctx.lineWidth = 2;
  ctx.stroke();
  t += 1;
  requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>
Open page in browser

This version correctly binds each slider to its value and allows reversing wave direction by setting a negative speed.

That works, except the numbers over the sliders dont change (except for phase). Can we now include the possibility to add extra waves. As we add a eave we get an additional input box where we can change parameters of the new wave. The individual waves can appear translucent on the main display, with the superposition solid and slightly thicker. We could also have an X on each input box that allows us to delete individual waves. If that makes sense please go ahead and update the code, if you have any questions let me know?

That makes perfect sense — dynamic wave panels (each with wavelength, amplitude, speed, phase, and delete button), translucent individual waves, and a solid summed wave. No questions — I can implement all that cleanly. Would you like me to keep the styling minimal (like before) or add some light color accents to make each wave panel visually distinct?

Yep please add in some colours, and adjust the layout so that the main display comes directly below the app title

Got it — I’ll add soft color accents for each wave control panel, position the canvas directly under the title, and keep the layout clean and readable. Ready to show the full updated code?

Yep

Here’s the complete, colorful, multi-wave version:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wave Superposition App</title>
<style>
  body { font-family: sans-serif; background: #f5f5f5; text-align: center; }
  h2 { margin-top: 20px; }
  canvas { display: block; margin: 10px auto; background: white; border: 1px solid #ccc; }
  #controls { width: 90%; max-width: 700px; margin: 15px auto; text-align: left; }
  .wave-box {
    background: #eaf4ff;
    border: 1px solid #bcd4f6;
    border-radius: 8px;
    padding: 10px;
    margin-bottom: 10px;
    position: relative;
  }
  .wave-box:nth-child(odd) { background: #eaf9ea; border-color: #bde5b8; }
  .wave-box label { display: block; margin: 4px 0 2px; font-size: 0.9em; }
  .wave-box input[type=range] { width: 100%; }
  .remove-btn {
    position: absolute; top: 6px; right: 8px;
    background: #ff6666; color: white; border: none;
    border-radius: 50%; width: 22px; height: 22px; cursor: pointer;
  }
  #addWave {
    background: #0077cc; color: white; border: none;
    border-radius: 6px; padding: 8px 14px; cursor: pointer; margin-top: 10px;
  }
</style>
</head>
<body>
<h2>Wave Superposition Visualizer</h2>
<canvas id="canvas" width="800" height="300"></canvas>

<div id="controls"></div>
<button id="addWave">+ Add Wave</button>

<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const controlsDiv = document.getElementById('controls');
const addBtn = document.getElementById('addWave');

let waves = [];
let t = 0;

function createWavePanel(id) {
  const box = document.createElement('div');
  box.className = 'wave-box';
  box.innerHTML = `
    <button class="remove-btn">&times;</button>
    <label>Wavelength: <span class="wlVal">100</span></label>
    <input type="range" class="wavelength" min="20" max="300" value="100">
    <label>Amplitude: <span class="ampVal">50</span></label>
    <input type="range" class="amplitude" min="10" max="100" value="50">
    <label>Speed: <span class="spdVal">0.05</span></label>
    <input type="range" class="speed" min="-0.2" max="0.2" step="0.01" value="0.05">
    <label>Phase: <span class="phVal">0</span></label>
    <input type="range" class="phase" min="0" max="6.28" step="0.1" value="0">
  `;
  const wave = { wavelength:100, amplitude:50, speed:0.05, phase:0, color:getColor() };
  waves.push(wave);

  const update = () => {
    wave.wavelength = parseFloat(box.querySelector('.wavelength').value);
    wave.amplitude = parseFloat(box.querySelector('.amplitude').value);
    wave.speed = parseFloat(box.querySelector('.speed').value);
    wave.phase = parseFloat(box.querySelector('.phase').value);
    box.querySelector('.wlVal').textContent = wave.wavelength;
    box.querySelector('.ampVal').textContent = wave.amplitude;
    box.querySelector('.spdVal').textContent = wave.speed.toFixed(2);
    box.querySelector('.phVal').textContent = wave.phase.toFixed(1);
  };

  box.querySelectorAll('input').forEach(input => input.addEventListener('input', update));
  box.querySelector('.remove-btn').onclick = () => {
    controlsDiv.removeChild(box);
    waves.splice(id,1);
  };

  controlsDiv.appendChild(box);
  update();
}

function getColor() {
  const colors = ['#ff6666','#66b3ff','#66cc66','#ffcc66','#cc99ff'];
  return colors[waves.length % colors.length];
}

function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const yCenter = canvas.height / 2;

  // Draw each individual wave
  waves.forEach(w => {
    ctx.beginPath();
    for (let x = 0; x < canvas.width; x++) {
      const y = yCenter + w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.strokeStyle = w.color + '80'; // translucent
    ctx.lineWidth = 1.5;
    ctx.stroke();
  });

  // Draw sum wave
  ctx.beginPath();
  for (let x = 0; x < canvas.width; x++) {
    let ySum = 0;
    waves.forEach(w => {
      ySum += w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
    });
    const y = yCenter + ySum;
    x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 2.5;
  ctx.stroke();

  t += 1;
  requestAnimationFrame(draw);
}

addBtn.onclick = () => createWavePanel(waves.length);
createWavePanel(0);
draw();
</script>
</body>
</html>
Open page in browser

✅ Features:

  • Add/delete waves dynamically
  • Color-coded translucent individual waves
  • Solid black summed wave
  • Responsive sliders with live value display
  • Canvas sits directly under the title for clarity

Can we include some present examples. 1. Destructive interference (2 waves with equal speed but 180 degrees out of phase) 2. constructive interference (2 waves with slightly different amplitudes but same phase and speed) 3. standing wave, 4. spring neap cycle (2 waves, with slightly different wavelengths creating a modulated tidal like pattern), 5. deep water wave where the speed of the wave is set proportional to the root of the wavelength (can add as many waves as desired but speeds are constrained). Does this make sense? Are there any other useful scenarios?

Yes, that makes perfect sense — those five presets neatly cover the main interference and wave interaction phenomena students should explore.

Additional useful scenarios you might consider:

  • Beats (two close frequencies, highlighting amplitude modulation — distinct from the spring-neap example if you vary speed instead of wavelength).
  • Dispersion (multiple wavelengths with speeds increasing with wavelength, showing wave packet spreading).
  • Random superposition (several random waves to illustrate how complex patterns can emerge).

Would you like me to add a dropdown for these presets next (to auto-load example parameters)?

yes my no.5 was meant to be Dispersion. SO we would need to fix the wave speeds to be proportional to the root of the wavelength, And beats is my spring neap scenario. So lets just go with my options. Can you include them as preset buttons. We should still be able add and modify waves after clicking on a preset. For the dispersion case we just need to make it clear that the speed is constrained. Could we also add an educational help box that explains the various scenarios e.g. for Constructive interference: The complex ocean surface patterns arise from multiple waves superimposing as they interact. When waves meet, the water displacement at any point equals the sum of the individual wave displacements. Constructive interference occurs when this sum produces a larger displacement—for example, when two crests (or troughs) align, the resulting wave height momentarily equals the sum of both wave heights before the waves continue propagating separately.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wave Superposition App</title>
<style>
  :root { --bg:#f6f7fb; --card:#ffffff; --ink:#111; --muted:#666; --accent:#2563eb; }
  body { margin:0; font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell; background:var(--bg); color:var(--ink); }
  h1 { text-align:center; margin:18px 0 8px; font-weight:700; }
  .sub { text-align:center; color:var(--muted); margin-bottom:10px; }
  canvas { display:block; margin:8px auto 14px; background: #fff; border:1px solid #ddd; border-radius:10px; max-width:900px; width:95%; height:360px }
  .bar { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:0 auto 8px; max-width:960px; }
  .btn { border:1px solid #cfd6e4; background:#fff; padding:8px 10px; border-radius:10px; cursor:pointer; font-weight:600; }
  .btn:hover { border-color:#9fb3d9; }
  .btn.primary { background:var(--accent); color:#fff; border-color:transparent; }
  .flag { font-size:12px; color:#0a6; font-weight:600; display:none; align-items:center; gap:6px; }
  .flag.on { display:inline-flex; }
  #help { max-width:960px; margin:6px auto 10px; }
  details { background:var(--card); border:1px solid #e5e7eb; border-radius:12px; padding:10px 12px; }
  summary { cursor:pointer; font-weight:700; }
  .help-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:10px; }
  .help-card { background:#fafafa; border:1px dashed #e2e8f0; border-radius:10px; padding:8px 10px; font-size:14px; line-height:1.35; }
  #controls { width:95%; max-width:960px; margin:12px auto 60px; display:grid; gap:10px; }
  .wave-box {
    position:relative; border-radius:12px; padding:10px 12px; border:1px solid #e5e7eb; background:#fff;
  }
  .wave-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; }
  .tag { display:inline-block; font-weight:700; font-size:13px; padding:2px 8px; border-radius:999px; color:#fff; }
  .remove { border:none; background:#ef4444; color:#fff; width:26px; height:26px; border-radius:50%; cursor:pointer; font-weight:700; }
  .row { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; }
  label { font-size:12px; color:#334155; display:block; margin-bottom:2px; }
  .sl { width:100%; }
  .val { font-variant-numeric:tabular-nums; color:#0f172a; font-weight:700; }
  .muted { color:#64748b; font-size:12px }
  @media (max-width:720px) { .row { grid-template-columns:1fr; } }
</style>
</head>
<body>
  <h1>Wave Superposition Visualizer</h1>
  <div class="sub">
    <span id="dispersionFlag" class="flag">• Dispersion mode (speed ∝ √wavelength)</span>
  </div>
  <canvas id="canvas"></canvas>

  <div class="bar">
    <button class="btn primary" data-preset="destructive">Destructive interference</button>
    <button class="btn" data-preset="constructive">Constructive interference</button>
    <button class="btn" data-preset="standing">Standing wave</button>
    <button class="btn" data-preset="springneap">Spring–neap (beats)</button>
    <button class="btn" data-preset="dispersion">Dispersion</button>
    <button class="btn" id="addWaveBtn">+ Add wave</button>
  </div>

  <div id="help">
    <details>
      <summary>Educational notes (click to expand)</summary>
      <div class="help-grid">
        <div class="help-card"><b>Constructive interference</b><br>
          The surface displacement equals the sum of all waves. When crests (or troughs) align, the resulting height momentarily equals the sum of their heights before they continue propagating independently.
        </div>
        <div class="help-card"><b>Destructive interference</b><br>
          Two equal-amplitude waves 180° out of phase cancel: crest meets trough → near-zero net displacement.
        </div>
        <div class="help-card"><b>Standing wave</b><br>
          Two equal waves traveling in opposite directions form nodes (no motion) and antinodes (max motion).
        </div>
        <div class="help-card"><b>Spring–neap (beats)</b><br>
          Slightly different frequencies (or wavelengths) produce slow amplitude modulation: alternating large (“spring”) and small (“neap”) envelopes.
        </div>
        <div class="help-card"><b>Dispersion</b><br>
          In deep water, phase speed increases with wavelength. Here we constrain speed ∝ √λ for all waves; adjusting λ updates speed automatically.
        </div>
      </div>
    </details>
  </div>

  <div id="controls"></div>

<script>
/* ---------- Canvas setup ---------- */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function fitCanvas() {
  const w = Math.min(900, Math.floor(window.innerWidth * 0.95));
  canvas.width = w; canvas.height = 360;
}
fitCanvas(); addEventListener('resize', fitCanvas);

/* ---------- State ---------- */
const colors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#a855f7'];
let waves = []; // {λ, A, s, φ, color}
let t = 0;
let dispersionMode = false; // when true, lock s = k*sqrt(λ)
const SPEED_K = 0.018;      // proportionality constant for dispersion
const MAX_WAVES = 5;

const controlsDiv = document.getElementById('controls');
const addWaveBtn = document.getElementById('addWaveBtn');
const dispersionFlag = document.getElementById('dispersionFlag');

/* ---------- Helpers ---------- */
function makeTagStyle(i){ return `background:${colors[i%colors.length]}`; }
function rad(v){ return Number(v).toFixed(2); }

function addWave({wavelength=120, amplitude=50, speed=0.05, phase=0} = {}) {
  if (waves.length >= MAX_WAVES) return;

  const idx = waves.length;
  const wave = { wavelength, amplitude, speed, phase, color: colors[idx%colors.length] };
  waves.push(wave);

  const box = document.createElement('div');
  box.className = 'wave-box';
  box.innerHTML = `
    <div class="wave-head">
      <span class="tag" style="${makeTagStyle(idx)}">Wave ${idx+1}</span>
      <button class="remove" title="Remove wave">×</button>
    </div>

    <div class="row">
      <div>
        <label>Wavelength λ: <span class="val wlVal">${wavelength}</span></label>
        <input class="sl wavelength" type="range" min="20" max="300" value="${wavelength}">
      </div>
      <div>
        <label>Amplitude A: <span class="val ampVal">${amplitude}</span></label>
        <input class="sl amplitude" type="range" min="5" max="120" value="${amplitude}">
      </div>
      <div>
        <label>Speed s: <span class="val spdVal">${speed.toFixed(3)}</span> <span class="muted spdNote"></span></label>
        <input class="sl speed" type="range" min="-0.25" max="0.25" step="0.005" value="${speed}">
      </div>
      <div>
        <label>Phase φ (rad): <span class="val phVal">${rad(phase)}</span></label>
        <input class="sl phase" type="range" min="0" max="6.283" step="0.05" value="${phase}">
      </div>
    </div>
  `;
  controlsDiv.appendChild(box);

  const q = {
    wl:   box.querySelector('.wavelength'),
    amp:  box.querySelector('.amplitude'),
    spd:  box.querySelector('.speed'),
    ph:   box.querySelector('.phase'),
    wlVal: box.querySelector('.wlVal'),
    ampVal: box.querySelector('.ampVal'),
    spdVal: box.querySelector('.spdVal'),
    phVal: box.querySelector('.phVal'),
    spdNote: box.querySelector('.spdNote')
  };

  function syncFromUI() {
    wave.wavelength = parseFloat(q.wl.value);
    wave.amplitude  = parseFloat(q.amp.value);
    if (!dispersionMode) {
      wave.speed = parseFloat(q.spd.value);
      q.spdNote.textContent = '';
    } else {
      wave.speed = Math.sign(wave.speed||1) * SPEED_K * Math.sqrt(wave.wavelength);
      q.spd.value = wave.speed;
      q.spdNote.textContent = '(locked to √λ)';
    }
    wave.phase     = parseFloat(q.ph.value);

    q.wlVal.textContent = wave.wavelength;
    q.ampVal.textContent = wave.amplitude;
    q.spdVal.textContent = Number(wave.speed).toFixed(3);
    q.phVal.textContent  = rad(wave.phase);
  }

  [q.wl,q.amp,q.spd,q.ph].forEach(inp => inp.addEventListener('input', syncFromUI));
  box.querySelector('.remove').addEventListener('click', () => {
    waves = waves.filter(w => w !== wave);
    box.remove();
    relabel();
  });

  // Disable/enable speed control depending on mode
  function applyDispersionLock() {
    if (dispersionMode) {
      q.spd.disabled = true;
      q.spdNote.textContent = '(locked to √λ)';
    } else {
      q.spd.disabled = false;
      q.spdNote.textContent = '';
    }
  }

  applyDispersionLock();
  syncFromUI();
  return wave;
}

function relabel() {
  [...document.querySelectorAll('.wave-box .tag')].forEach((el,i)=>{
    el.textContent = `Wave ${i+1}`;
    el.style = makeTagStyle(i);
  });
}

function clearWaves() {
  waves = [];
  controlsDiv.innerHTML = '';
}

function setDispersion(on) {
  dispersionMode = on;
  dispersionFlag.classList.toggle('on', on);
  document.querySelectorAll('.wave-box').forEach(box=>{
    const spd = box.querySelector('.speed');
    const note = box.querySelector('.spdNote');
    spd.disabled = on;
    note.textContent = on ? '(locked to √λ)' : '';
  });
  // Update speeds for all waves when turning on
  if (on) {
    waves.forEach(w => { w.speed = Math.sign(w.speed||1) * SPEED_K * Math.sqrt(w.wavelength); });
    // reflect in UI
    document.querySelectorAll('.wave-box').forEach((box,i)=>{
      const spdVal = box.querySelector('.spdVal');
      const spd = box.querySelector('.speed');
      spd.value = waves[i].speed;
      spdVal.textContent = waves[i].speed.toFixed(3);
    });
  }
}

/* ---------- Presets ---------- */
const presets = {
  destructive() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:0          });
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:Math.PI    });
  },
  constructive() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:140, amplitude:50, speed:0.05, phase:0 });
    addWave({ wavelength:140, amplitude:35, speed:0.05, phase:0 });
  },
  standing() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:150, amplitude:45, speed: 0.07, phase:0 });
    addWave({ wavelength:150, amplitude:45, speed:-0.07, phase:0 }); // opposite direction
  },
  springneap() { // beats via close wavelengths
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:140, amplitude:45, speed:0.06, phase:0   });
    addWave({ wavelength:150, amplitude:45, speed:0.06, phase:0.0 });
  },
  dispersion() {
    clearWaves();
    setDispersion(true);
    addWave({ wavelength:80,  amplitude:35, speed:SPEED_K*Math.sqrt(80),  phase:0 });
    addWave({ wavelength:140, amplitude:28, speed:SPEED_K*Math.sqrt(140), phase:1.2 });
    addWave({ wavelength:220, amplitude:22, speed:SPEED_K*Math.sqrt(220), phase:2.0 });
  }
};

document.querySelectorAll('[data-preset]').forEach(b=>{
  b.addEventListener('click', () => {
    presetsb.dataset.preset;
  });
});
addWaveBtn.addEventListener('click', () => addWave());

/* ---------- Animation ---------- */
function draw() {
  ctx.clearRect(0,0,canvas.width,canvas.height);
  const H = canvas.height, W = canvas.width, yc = H/2;

  // individual waves (translucent)
  waves.forEach((w,i)=>{
    ctx.beginPath();
    for (let x=0; x<W; x++) {
      const y = yc + w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
      x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
    }
    ctx.strokeStyle = hexToRgba(w.color, 0.55);
    ctx.lineWidth = 1.4;
    ctx.stroke();
  });

  // sum wave (solid)
  ctx.beginPath();
  for (let x=0; x<W; x++) {
    let s = 0;
    for (const w of waves) {
      s += w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
    }
    const y = yc + s;
    x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
  }
  ctx.strokeStyle = '#0f172a';
  ctx.lineWidth = 2.8;
  ctx.stroke();

  t += 1;
  requestAnimationFrame(draw);
}

function hexToRgba(hex, a){
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  const r = parseInt(m[1],16), g = parseInt(m[2],16), b = parseInt(m[3],16);
  return `rgba(${r},${g},${b},${a})`;
}

/* ---------- Boot ---------- */
presets.constructive(); // default
draw();
</script>
</body>
</html>
Open page in browser

Looks good. Can we add a scenario called default that is our starting single wave configuration. Also another scenario called Wave Sets, that show that a combination of waves with different phase speeds can generate wave sets with a different group speed

Yes. I’ll add two preset buttons:

  • Default — clears to a single traveling wave (e.g., λ=160, A=60, s=0.06, φ=0), dispersion off.
  • Wave Sets — enables dispersion mode (speed ∝ √λ) and loads a narrow-band packet (e.g., λ ≈ [140,150,155,160,170] with tapered amplitudes). The crests run through the envelope while the envelope (group) moves more slowly, illustrating distinct phase vs group speed.

You’ll still be able to add/edit waves after selecting either preset.
Want me to post the updated code?

Yes can still edit after pressing on presets. Yes please provide the code. Also the sqare root in the seep water explanation isnt rendering - I see (cproptosqrt{lambda})

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wave Superposition App</title>
<style>
  :root { --bg:#f6f7fb; --card:#ffffff; --ink:#111; --muted:#666; --accent:#2563eb; }
  body { margin:0; font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell; background:var(--bg); color:var(--ink); }
  h1 { text-align:center; margin:18px 0 8px; font-weight:700; }
  .sub { text-align:center; color:var(--muted); margin-bottom:10px; }
  canvas { display:block; margin:8px auto 14px; background:#fff; border:1px solid #ddd; border-radius:10px; max-width:900px; width:95%; height:360px }
  .bar { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:0 auto 8px; max-width:960px; }
  .btn { border:1px solid #cfd6e4; background:#fff; padding:8px 10px; border-radius:10px; cursor:pointer; font-weight:600; }
  .btn:hover { border-color:#9fb3d9; }
  .btn.primary { background:var(--accent); color:#fff; border-color:transparent; }
  .flag { font-size:12px; color:#0a6; font-weight:600; display:none; align-items:center; gap:6px; }
  .flag.on { display:inline-flex; }
  #help { max-width:960px; margin:6px auto 10px; }
  details { background:var(--card); border:1px solid #e5e7eb; border-radius:12px; padding:10px 12px; }
  summary { cursor:pointer; font-weight:700; }
  .help-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:10px; }
  .help-card { background:#fafafa; border:1px dashed #e2e8f0; border-radius:10px; padding:8px 10px; font-size:14px; line-height:1.35; }
  #controls { width:95%; max-width:960px; margin:12px auto 60px; display:grid; gap:10px; }
  .wave-box { position:relative; border-radius:12px; padding:10px 12px; border:1px solid #e5e7eb; background:#fff; }
  .wave-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; }
  .tag { display:inline-block; font-weight:700; font-size:13px; padding:2px 8px; border-radius:999px; color:#fff; }
  .remove { border:none; background:#ef4444; color:#fff; width:26px; height:26px; border-radius:50%; cursor:pointer; font-weight:700; }
  .row { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; }
  label { font-size:12px; color:#334155; display:block; margin-bottom:2px; }
  .sl { width:100%; }
  .val { font-variant-numeric:tabular-nums; color:#0f172a; font-weight:700; }
  .muted { color:#64748b; font-size:12px }
  @media (max-width:720px) { .row { grid-template-columns:1fr; } }
</style>
</head>
<body>
  <h1>Wave Superposition Visualizer</h1>
  <div class="sub">
    <span id="dispersionFlag" class="flag">• Dispersion mode (speed ∝ √λ)</span>
  </div>
  <canvas id="canvas"></canvas>

  <div class="bar">
    <button class="btn primary" data-preset="default">Default</button>
    <button class="btn" data-preset="destructive">Destructive</button>
    <button class="btn" data-preset="constructive">Constructive</button>
    <button class="btn" data-preset="standing">Standing</button>
    <button class="btn" data-preset="springneap">Spring–neap</button>
    <button class="btn" data-preset="wavesets">Wave Sets</button>
    <button class="btn" data-preset="dispersion">Dispersion</button>
    <button class="btn" id="addWaveBtn">+ Add wave</button>
  </div>

  <div id="help">
    <details>
      <summary>Educational notes (click to expand)</summary>
      <div class="help-grid">
        <div class="help-card"><b>Default</b><br>Single traveling sine wave you can modify freely.</div>
        <div class="help-card"><b>Constructive interference</b><br>
          When crests (or troughs) align, the summed displacement increases; height ≈ sum of the individual heights.
        </div>
        <div class="help-card"><b>Destructive interference</b><br>
          Equal-amplitude waves 180° out of phase cancel (crest + trough → near-zero net displacement).
        </div>
        <div class="help-card"><b>Standing wave</b><br>
          Two equal waves traveling in opposite directions create nodes (no motion) and antinodes (max motion).
        </div>
        <div class="help-card"><b>Spring–neap (beats)</b><br>
          Slightly different wavelengths (or frequencies) produce a slow amplitude envelope: alternating “spring” and “neap”.
        </div>
        <div class="help-card"><b>Wave Sets</b><br>
          A narrow band of wavelengths forms a moving envelope (group). Crests move through the envelope (phase speed) while the envelope itself moves at a different group speed.
        </div>
        <div class="help-card"><b>Dispersion</b><br>
          Deep water: phase speed increases with wavelength. Here speed is constrained: speed ∝ √λ (adjusting λ updates speed).
        </div>
      </div>
    </details>
  </div>

  <div id="controls"></div>

<script>
/* ---------- Canvas ---------- */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function fitCanvas(){ const w=Math.min(900,Math.floor(window.innerWidth*0.95)); canvas.width=w; canvas.height=360; }
fitCanvas(); addEventListener('resize', fitCanvas);

/* ---------- State ---------- */
const colors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#a855f7'];
let waves = []; // {wavelength, amplitude, speed, phase, color}
let t = 0;
let dispersionMode = false;
const SPEED_K = 0.018; // speed = ±K*sqrt(lambda) in dispersion mode
const MAX_WAVES = 5;

const controlsDiv = document.getElementById('controls');
const addWaveBtn = document.getElementById('addWaveBtn');
const dispersionFlag = document.getElementById('dispersionFlag');

/* ---------- UI helpers ---------- */
function makeTagStyle(i){ return `background:${colors[i%colors.length]}`; }
function rad(v){ return Number(v).toFixed(2); }
function hexToRgba(hex, a){
  const m=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  const r=parseInt(m[1],16), g=parseInt(m[2],16), b=parseInt(m[3],16);
  return `rgba(${r},${g},${b},${a})`;
}

/* ---------- Wave panels ---------- */
function addWave({wavelength=160, amplitude=60, speed=0.06, phase=0} = {}) {
  if (waves.length >= MAX_WAVES) return;
  const idx = waves.length;
  const wave = { wavelength, amplitude, speed, phase, color: colors[idx%colors.length] };
  waves.push(wave);

  const box = document.createElement('div');
  box.className = 'wave-box';
  box.innerHTML = `
    <div class="wave-head">
      <span class="tag" style="${makeTagStyle(idx)}">Wave ${idx+1}</span>
      <button class="remove" title="Remove wave">×</button>
    </div>
    <div class="row">
      <div>
        <label>Wavelength λ: <span class="val wlVal">${wavelength}</span></label>
        <input class="sl wavelength" type="range" min="20" max="300" value="${wavelength}">
      </div>
      <div>
        <label>Amplitude A: <span class="val ampVal">${amplitude}</span></label>
        <input class="sl amplitude" type="range" min="5" max="120" value="${amplitude}">
      </div>
      <div>
        <label>Speed s: <span class="val spdVal">${speed.toFixed(3)}</span> <span class="muted spdNote"></span></label>
        <input class="sl speed" type="range" min="-0.25" max="0.25" step="0.005" value="${speed}">
      </div>
      <div>
        <label>Phase φ (rad): <span class="val phVal">${rad(phase)}</span></label>
        <input class="sl phase" type="range" min="0" max="6.283" step="0.05" value="${phase}">
      </div>
    </div>
  `;
  controlsDiv.appendChild(box);

  const q = {
    wl: box.querySelector('.wavelength'),
    amp: box.querySelector('.amplitude'),
    spd: box.querySelector('.speed'),
    ph: box.querySelector('.phase'),
    wlVal: box.querySelector('.wlVal'),
    ampVal: box.querySelector('.ampVal'),
    spdVal: box.querySelector('.spdVal'),
    phVal: box.querySelector('.phVal'),
    spdNote: box.querySelector('.spdNote')
  };

  function syncFromUI(){
    wave.wavelength = parseFloat(q.wl.value);
    wave.amplitude  = parseFloat(q.amp.value);
    if (!dispersionMode) {
      wave.speed = parseFloat(q.spd.value);
      q.spdNote.textContent = '';
    } else {
      const sign = Math.sign(wave.speed||1); // keep previous direction
      wave.speed = sign * SPEED_K * Math.sqrt(wave.wavelength);
      q.spd.value = wave.speed;
      q.spdNote.textContent = '(locked to √λ)';
    }
    wave.phase = parseFloat(q.ph.value);

    q.wlVal.textContent = wave.wavelength;
    q.ampVal.textContent = wave.amplitude;
    q.spdVal.textContent = Number(wave.speed).toFixed(3);
    q.phVal.textContent  = rad(wave.phase);
  }

  [q.wl,q.amp,q.spd,q.ph].forEach(inp => inp.addEventListener('input', syncFromUI));
  box.querySelector('.remove').addEventListener('click', () => {
    waves = waves.filter(w => w !== wave);
    box.remove();
    relabel();
  });

  function applyDispersionLock(){
    if (dispersionMode) { q.spd.disabled = true; q.spdNote.textContent='(locked to √λ)'; }
    else { q.spd.disabled = false; q.spdNote.textContent=''; }
  }

  applyDispersionLock();
  syncFromUI();
  return wave;
}

function relabel(){
  [...document.querySelectorAll('.wave-box .tag')].forEach((el,i)=>{
    el.textContent = `Wave ${i+1}`;
    el.style = makeTagStyle(i);
  });
}

function clearWaves(){ waves=[]; controlsDiv.innerHTML=''; }

function setDispersion(on){
  dispersionMode = on;
  dispersionFlag.classList.toggle('on', on);
  document.querySelectorAll('.wave-box').forEach(box=>{
    const spd = box.querySelector('.speed');
    const note = box.querySelector('.spdNote');
    spd.disabled = on; note.textContent = on ? '(locked to √λ)' : '';
  });
  if (on) {
    waves.forEach(w => { const sign=Math.sign(w.speed||1); w.speed = sign*SPEED_K*Math.sqrt(w.wavelength); });
    document.querySelectorAll('.wave-box').forEach((box,i)=>{
      box.querySelector('.speed').value = waves[i].speed;
      box.querySelector('.spdVal').textContent = waves[i].speed.toFixed(3);
    });
  }
}

/* ---------- Presets ---------- */
const presets = {
  default() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:160, amplitude:60, speed:0.06, phase:0 });
  },
  destructive() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:0 });
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:Math.PI });
  },
  constructive() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:140, amplitude:50, speed:0.05, phase:0 });
    addWave({ wavelength:140, amplitude:35, speed:0.05, phase:0 });
  },
  standing() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:150, amplitude:45, speed: 0.07, phase:0 });
    addWave({ wavelength:150, amplitude:45, speed:-0.07, phase:0 });
  },
  springneap() {
    setDispersion(false);
    clearWaves();
    addWave({ wavelength:140, amplitude:45, speed:0.06, phase:0 });
    addWave({ wavelength:150, amplitude:45, speed:0.06, phase:0 });
  },
  wavesets() {
    // Narrow-band packet; dispersion ON. Keep mixed phase directions to show crests running through envelope.
    clearWaves();
    setDispersion(true);
    const specs = [
      { λ:150, A:22, φ:0.0,  sg:+1 },
      { λ:155, A:28, φ:0.6,  sg:+1 },
      { λ:160, A:34, φ:1.0,  sg:+1 },
      { λ:165, A:28, φ:1.5,  sg:+1 },
      { λ:170, A:22, φ:2.2,  sg:+1 }
    ];
    specs.forEach(s=>{
      const spd = s.sg * SPEED_K * Math.sqrt(s.λ);
      addWave({ wavelength:s.λ, amplitude:s.A, speed:spd, phase:s.φ });
    });
  },
  dispersion() {
    clearWaves();
    setDispersion(true);
    addWave({ wavelength:80,  amplitude:35, speed:SPEED_K*Math.sqrt(80),  phase:0 });
    addWave({ wavelength:140, amplitude:28, speed:SPEED_K*Math.sqrt(140), phase:1.2 });
    addWave({ wavelength:220, amplitude:22, speed:SPEED_K*Math.sqrt(220), phase:2.0 });
  }
};

document.querySelectorAll('[data-preset]').forEach(b=>{
  b.addEventListener('click', () => presetsb.dataset.preset);
});
addWaveBtn.addEventListener('click', () => addWave());

/* ---------- Animation ---------- */
function draw(){
  ctx.clearRect(0,0,canvas.width,canvas.height);
  const H=canvas.height, W=canvas.width, yc=H/2;

  // individual waves
  for (const w of waves){
    ctx.beginPath();
    for (let x=0;x<W;x++){
      const y = yc + w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
      x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
    }
    ctx.strokeStyle = hexToRgba(w.color,0.55);
    ctx.lineWidth = 1.4;
    ctx.stroke();
  }

  // sum wave
  ctx.beginPath();
  for (let x=0;x<W;x++){
    let s=0;
    for (const w of waves){
      s += w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
    }
    const y = yc + s;
    x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
  }
  ctx.strokeStyle = '#0f172a';
  ctx.lineWidth = 2.8;
  ctx.stroke();

  t += 1;
  requestAnimationFrame(draw);
}

/* ---------- Boot ---------- */
presets.default();
draw();
</script>
</body>
</html>
Open page in browser

We have lost some of the changes made previously e.g. the add wave seperate to the scenarios; the zero speed and shorter wavelengths for the spring neaps; the vertical zoom; highlighting the active scenario button I want the educational notes panel to always be open, but the content changes depending on which scenario is presses. For the defaul we can add instructions ". you can add multiple waves, change their characteristics and see how they combine (superposition). You can also try out some preset scenarios that demonstrate some special cases". The sets scenario doesnt work very well, we could do it with just two waves L=-49, A=-50, s=-0.05, phase =0 and L=53, A=50, s=0.06 phase =0. Do these changes make sense?

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wave Superposition App</title>
<style>
  :root { --bg:#f6f7fb; --card:#ffffff; --ink:#111; --muted:#667085; --accent:#2563eb; }
  *{box-sizing:border-box}
  body { margin:0; font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell; background:var(--bg); color:var(--ink); }
  h1 { text-align:center; margin:18px 0 6px; font-weight:700; }
  .sub { text-align:center; color:var(--muted); margin:0 0 8px; }
  .flag { font-size:12px; color:#0a6; font-weight:600; display:none; }
  .flag.on { display:inline; }
  canvas { display:block; margin:10px auto; background:#fff; border:1px solid #ddd; border-radius:10px; max-width:900px; width:95%; height:360px }
  .bar { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin:6px auto 8px; max-width:980px; }
  .btn { border:1px solid #cfd6e4; background:#fff; padding:8px 10px; border-radius:10px; cursor:pointer; font-weight:600; }
  .btn:hover { border-color:#9fb3d9; }
  .btn.primary { background:var(--accent); color:#fff; border-color:transparent; }
  .btn.active { outline:2px solid var(--accent); }
  .controls-wrap { width:95%; max-width:980px; margin:10px auto 60px; display:grid; gap:10px; }
  .panel { background:var(--card); border:1px solid #e5e7eb; border-radius:12px; padding:10px 12px; }
  .panel h3 { margin:0 0 6px; font-size:15px; }
  #edu { line-height:1.4; font-size:14px; }
  /* wave panels */
  #controls { display:grid; gap:10px; }
  .wave-box { position:relative; border-radius:12px; padding:10px 12px; border:1px solid #e5e7eb; background:#fff; }
  .wave-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; }
  .tag { display:inline-block; font-weight:700; font-size:13px; padding:2px 8px; border-radius:999px; color:#fff; }
  .remove { border:none; background:#ef4444; color:#fff; width:26px; height:26px; border-radius:50%; cursor:pointer; font-weight:700; }
  .row { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; }
  label { font-size:12px; color:#334155; display:block; margin-bottom:2px; }
  .sl { width:100%; }
  .val { font-variant-numeric:tabular-nums; color:#0f172a; font-weight:700; }
  .muted { color:#64748b; font-size:12px }
  @media (max-width:720px) { .row { grid-template-columns:1fr; } }
</style>
</head>
<body>
  <h1>Wave Superposition Visualizer</h1>
  <div class="sub">
    <span id="dispersionFlag" class="flag">• Dispersion mode (speed ∝ √λ)</span>
  </div>

  <canvas id="canvas"></canvas>

  <div class="bar" id="presetBar">
    <button class="btn primary" data-preset="default">Default</button>
    <button class="btn" data-preset="destructive">Destructive</button>
    <button class="btn" data-preset="constructive">Constructive</button>
    <button class="btn" data-preset="standing">Standing</button>
    <button class="btn" data-preset="springneap">Spring–neap</button>
    <button class="btn" data-preset="wavesets">Wave Sets</button>
    <button class="btn" data-preset="dispersion">Dispersion</button>
    <button class="btn" id="addWaveBtn">+ Add wave</button>
  </div>

  <div class="controls-wrap">
    <div class="panel">
      <h3>View controls</h3>
      <label>Vertical zoom (applies to all waves): <span id="zoomVal" class="val">1.00×</span></label>
      <input id="zoom" class="sl" type="range" min="0.25" max="3" step="0.05" value="1">
    </div>

    <div class="panel">
      <h3>Educational notes</h3>
      <div id="edu"></div>
    </div>

    <div id="controls"></div>
  </div>

<script>
/* ---------- Canvas ---------- */
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
function fitCanvas(){ const w=Math.min(900,Math.floor(window.innerWidth*0.95)); canvas.width=w; canvas.height=360; }
fitCanvas(); addEventListener('resize', fitCanvas);

/* ---------- State ---------- */
const colors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#a855f7'];
let waves = []; // {wavelength, amplitude, speed, phase, color}
let t = 0;
let dispersionMode = false;
let yZoom = 1;
const SPEED_K = 0.018; // speed = ±K*sqrt(lambda) in dispersion mode
const MAX_WAVES = 5;

const controlsDiv = document.getElementById('controls');
const addWaveBtn = document.getElementById('addWaveBtn');
const dispersionFlag = document.getElementById('dispersionFlag');
const zoom = document.getElementById('zoom');
const zoomVal = document.getElementById('zoomVal');
const edu = document.getElementById('edu');

/* ---------- UI helpers ---------- */
function makeTagStyle(i){ return `background:${colors[i%colors.length]}`; }
function rad(v){ return Number(v).toFixed(2); }
function hexToRgba(hex, a){
  const m=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  const r=parseInt(m[1],16), g=parseInt(m[2],16), b=parseInt(m[3],16);
  return `rgba(${r},${g},${b},${a})`;
}
function setActivePreset(btn){
  document.querySelectorAll('#presetBar .btn[data-preset]').forEach(b=>b.classList.remove('active'));
  if (btn) btn.classList.add('active');
}

/* ---------- Wave panels ---------- */
function addWave({wavelength=160, amplitude=60, speed=0.06, phase=0} = {}) {
  if (waves.length >= MAX_WAVES) return;
  const idx = waves.length;
  const wave = { wavelength, amplitude, speed, phase, color: colors[idx%colors.length] };
  waves.push(wave);

  const box = document.createElement('div');
  box.className = 'wave-box';
  box.innerHTML = `
    <div class="wave-head">
      <span class="tag" style="${makeTagStyle(idx)}">Wave ${idx+1}</span>
      <button class="remove" title="Remove wave">×</button>
    </div>
    <div class="row">
      <div>
        <label>Wavelength λ: <span class="val wlVal">${wavelength}</span></label>
        <input class="sl wavelength" type="range" min="20" max="300" value="${wavelength}">
      </div>
      <div>
        <label>Amplitude A: <span class="val ampVal">${amplitude}</span></label>
        <input class="sl amplitude" type="range" min="5" max="120" value="${amplitude}">
      </div>
      <div>
        <label>Speed s: <span class="val spdVal">${speed.toFixed(3)}</span> <span class="muted spdNote"></span></label>
        <input class="sl speed" type="range" min="-0.25" max="0.25" step="0.005" value="${speed}">
      </div>
      <div>
        <label>Phase φ (rad): <span class="val phVal">${rad(phase)}</span></label>
        <input class="sl phase" type="range" min="0" max="6.283" step="0.05" value="${phase}">
      </div>
    </div>
  `;
  controlsDiv.appendChild(box);

  const q = {
    wl: box.querySelector('.wavelength'),
    amp: box.querySelector('.amplitude'),
    spd: box.querySelector('.speed'),
    ph: box.querySelector('.phase'),
    wlVal: box.querySelector('.wlVal'),
    ampVal: box.querySelector('.ampVal'),
    spdVal: box.querySelector('.spdVal'),
    phVal: box.querySelector('.phVal'),
    spdNote: box.querySelector('.spdNote')
  };

  function syncFromUI(){
    wave.wavelength = parseFloat(q.wl.value);
    wave.amplitude  = parseFloat(q.amp.value);
    if (!dispersionMode) {
      wave.speed = parseFloat(q.spd.value);
      q.spdNote.textContent = '';
    } else {
      const sign = Math.sign(wave.speed||1); // keep previous direction
      wave.speed = sign * SPEED_K * Math.sqrt(wave.wavelength);
      q.spd.value = wave.speed;
      q.spdNote.textContent = '(locked to √λ)';
    }
    wave.phase = parseFloat(q.ph.value);

    q.wlVal.textContent = wave.wavelength;
    q.ampVal.textContent = wave.amplitude;
    q.spdVal.textContent = Number(wave.speed).toFixed(3);
    q.phVal.textContent  = rad(wave.phase);
  }

  [q.wl,q.amp,q.spd,q.ph].forEach(inp => inp.addEventListener('input', syncFromUI));
  box.querySelector('.remove').addEventListener('click', () => {
    waves = waves.filter(w => w !== wave);
    box.remove();
    relabel();
  });

  function applyDispersionLock(){
    if (dispersionMode) { q.spd.disabled = true; q.spdNote.textContent='(locked to √λ)'; }
    else { q.spd.disabled = false; q.spdNote.textContent=''; }
  }

  applyDispersionLock();
  syncFromUI();
  return wave;
}

function relabel(){
  [...document.querySelectorAll('.wave-box .tag')].forEach((el,i)=>{
    el.textContent = `Wave ${i+1}`;
    el.style = makeTagStyle(i);
  });
}

function clearWaves(){ waves=[]; controlsDiv.innerHTML=''; }

function setDispersion(on){
  dispersionMode = on;
  dispersionFlag.classList.toggle('on', on);
  document.querySelectorAll('.wave-box').forEach(box=>{
    const spd = box.querySelector('.speed');
    const note = box.querySelector('.spdNote');
    spd.disabled = on; note.textContent = on ? '(locked to √λ)' : '';
  });
  if (on) {
    waves.forEach(w => { const sign=Math.sign(w.speed||1); w.speed = sign*SPEED_K*Math.sqrt(w.wavelength); });
    document.querySelectorAll('.wave-box').forEach((box,i)=>{
      box.querySelector('.speed').value = waves[i].speed;
      box.querySelector('.spdVal').textContent = waves[i].speed.toFixed(3);
    });
  }
}

/* ---------- Presets & notes ---------- */
const NOTES = {
  default: `You can add multiple waves, change their characteristics and see how they combine (superposition).
  You can also try out some preset scenarios that demonstrate some special cases.`,
  destructive: `Two waves with equal amplitude and speed but 180° out of phase cancel: crest + trough → near-zero displacement.`,
  constructive: `Two nearly identical waves in phase add to produce larger peaks and troughs (height ≈ sum of heights).`,
  standing: `Equal waves traveling in opposite directions form nodes (no motion) and antinodes (max motion): a standing pattern.`,
  springneap: `Two close wavelengths produce a slow amplitude envelope (beats): alternating “spring” (large) and “neap” (small) amplitudes.`,
  wavesets: `A narrow band creates a moving envelope (group). Crests (phase) move through the envelope; the envelope travels at a distinct group speed.`,
  dispersion: `Deep water dispersion: phase speed increases with wavelength. Here speed is constrained: speed ∝ √λ (adjusting λ updates speed).`
};

function setNotes(key){ edu.textContent = NOTES[key]; }

const presets = {
  default(btn) {
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:160, amplitude:60, speed:0.06, phase:0 });
    setNotes('default');
  },
  destructive(btn) {
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:0 });
    addWave({ wavelength:160, amplitude:60, speed: 0.06, phase:Math.PI });
    setNotes('destructive');
  },
  constructive(btn) {
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:140, amplitude:50, speed:0.05, phase:0 });
    addWave({ wavelength:140, amplitude:35, speed:0.05, phase:0 });
    setNotes('constructive');
  },
  standing(btn) {
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:150, amplitude:45, speed: 0.07, phase:0 });
    addWave({ wavelength:150, amplitude:45, speed:-0.07, phase:0 });
    setNotes('standing');
  },
  springneap(btn) {
    // As requested: zero speeds and shorter wavelengths for a static beats pattern snapshot
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:40, amplitude:45, speed:0.00, phase:0 });
    addWave({ wavelength:44, amplitude:45, speed:0.00, phase:0 });
    setNotes('springneap');
  },
  wavesets(btn) {
    // Two-wave packet illustrating phase vs group speed (using your numbers; negatives interpreted as signs)
    setActivePreset(btn); setDispersion(false);
    clearWaves();
    addWave({ wavelength:49, amplitude:50, speed:-0.05, phase:0 });
    addWave({ wavelength:53, amplitude:50, speed: 0.06, phase:0 });
    setNotes('wavesets');
  },
  dispersion(btn) {
    setActivePreset(btn);
    clearWaves(); setDispersion(true);
    addWave({ wavelength:80,  amplitude:35, speed:SPEED_K*Math.sqrt(80),  phase:0 });
    addWave({ wavelength:140, amplitude:28, speed:SPEED_K*Math.sqrt(140), phase:1.2 });
    addWave({ wavelength:220, amplitude:22, speed:SPEED_K*Math.sqrt(220), phase:2.0 });
    setNotes('dispersion');
  }
};

/* ---------- Preset wiring ---------- */
document.querySelectorAll('[data-preset]').forEach(b=>{
  b.addEventListener('click', () => presetsb.dataset.preset);
});
addWaveBtn.addEventListener('click', () => addWave());

/* ---------- View controls ---------- */
zoom.addEventListener('input', e=>{ yZoom = parseFloat(e.target.value); zoomVal.textContent = `${yZoom.toFixed(2)}×`; });

/* ---------- Animation ---------- */
function draw(){
  ctx.clearRect(0,0,canvas.width,canvas.height);
  const H=canvas.height, W=canvas.width, yc=H/2;

  // individual waves
  for (const w of waves){
    ctx.beginPath();
    for (let x=0;x<W;x++){
      const y = yc + yZoom * ( w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t) );
      x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
    }
    ctx.strokeStyle = hexToRgba(w.color,0.55);
    ctx.lineWidth = 1.4;
    ctx.stroke();
  }

  // sum wave
  ctx.beginPath();
  for (let x=0;x<W;x++){
    let s=0;
    for (const w of waves){
      s += w.amplitude * Math.sin((2*Math.PI/w.wavelength)*x - w.phase - w.speed*t);
    }
    const y = yc + yZoom * s;
    x===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
  }
  ctx.strokeStyle = '#0f172a';
  ctx.lineWidth = 2.8;
  ctx.stroke();

  t += 1;
  requestAnimationFrame(draw);
}

/* ---------- Boot ---------- */
presets.default(document.querySelector('[data-preset="default"]'));
setNotes('default');
draw();
</script>
</body>
</html>
Open page in browser