'use strict';
/* ═══════════════════════════════════════════
   WavesSim v3  —  Волны и звук
   Modes: transverse | longitudinal | superposition | standing
          doppler | beats | spectrum
   ─────────────────────────────────────────── */
class WavesSim {
  static BG   = '#0D0D1A';
  static FONT = "700 12px 'Manrope',sans-serif";
  static V    = '#9B5DE5';  /* violet  */
  static C    = '#06D6E0';  /* cyan    */
  static P    = '#F15BB5';  /* pink    */
  static G    = '#FFD166';  /* gold    */

  constructor(canvas) {
    this._c   = canvas;
    this._ctx = canvas.getContext('2d');
    this._dpr = 1;
    this._W   = 0;
    this._H   = 0;

    this._mode   = 'transverse';
    this._t      = 0;
    this._last   = null;
    this._raf    = null;
    this._paused = true;

    this._A1   = 50;  this._f1   = 1.0;  this._phi1 = 0;
    this._A2   = 40;  this._f2   = 1.5;  this._phi2 = 0;
    this._n    = 1;
    this._speed = 2.0;

    /* doppler state */
    this._dopSrcX  = 0; this._dopSrcY  = 0;
    this._dopObsX  = 0; this._dopObsY  = 0;
    this._dopRings = [];        /* [{x,y,r,age}] */
    this._dopDrag  = null;      /* 'src'|'obs'|null */
    this._dopVs    = 0.35;      /* source speed, px/s as fraction of c_px */
    this._dopDir   = 1;         /* +1 right, -1 left */
    this._dopSrcVelX = 0;
    this._dopSrcVelY = 0;
    this._dopLastEmit = 0;

    /* beats state */
    this._beatsF1  = 440;
    this._beatsF2  = 444;

    /* spectrum state */
    this._specComponents = []; /* [{f, A}] */
    this._specNewF = 5;        /* Hz of component to add (slider) */

    this._resizeObs = null;
    this.onUpdate   = null;
  }

  /* ── публичное API ── */

  fit() {
    const par = this._c.parentElement;
    const dpr = window.devicePixelRatio || 1;
    const w   = par.clientWidth  || 600;
    const h   = par.clientHeight || 400;
    this._c.width  = Math.round(w * dpr);
    this._c.height = Math.round(h * dpr);
    this._dpr = dpr;
    this._W   = w;
    this._H   = h;
    if (this._resizeObs) this._resizeObs.disconnect();
    this._resizeObs = new ResizeObserver(() => this.fit());
    this._resizeObs.observe(par);
    this.draw();
  }

  setMode(mode) {
    this._mode = mode;
    this._t    = 0;
    this._last = null;
    if (mode === 'doppler') this._dopInit();
    if (mode === 'spectrum' && !this._specComponents.length)
      this._specComponents = [{ f: 5, A: 60 }, { f: 10, A: 30 }];
    if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
    this.draw();
    this._emit();
  }

  getParams() {
    return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
             n: this._n, speed: this._speed, mode: this._mode };
  }
  setParams({ A1, f1, phi1, A2, f2, phi2, n, speed,
              dopVs, beatsF1, beatsF2, specNewF } = {}) {
    if (A1     !== undefined) this._A1       = Math.max(5,   Math.min(90, +A1));
    if (f1     !== undefined) this._f1       = Math.max(0.3, Math.min(4,  +f1));
    if (phi1   !== undefined) this._phi1     = +phi1;
    if (A2     !== undefined) this._A2       = Math.max(5,   Math.min(90, +A2));
    if (f2     !== undefined) this._f2       = Math.max(0.3, Math.min(4,  +f2));
    if (phi2   !== undefined) this._phi2     = +phi2;
    if (n      !== undefined) this._n        = Math.max(1, Math.min(5, Math.round(+n)));
    if (speed  !== undefined) this._speed    = Math.max(0.3, Math.min(5, +speed));
    if (dopVs  !== undefined) this._dopVs    = Math.max(0, Math.min(1.8, +dopVs));
    if (beatsF1 !== undefined) this._beatsF1 = Math.max(1, Math.min(1000, +beatsF1));
    if (beatsF2 !== undefined) this._beatsF2 = Math.max(1, Math.min(1000, +beatsF2));
    if (specNewF !== undefined) this._specNewF = Math.max(1, Math.min(50, +specNewF));
    this.draw();
    this._emit();
  }

  play() {
    this._paused = false;
    this._last   = null;
    if (!this._raf) this._raf = requestAnimationFrame(t => this._tick(t));
  }
  pause() {
    this._paused = true;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }
  start() { this.play(); }
  stop()  { this.pause(); }
  reset() { this._t = 0; this._last = null; this.draw(); this._emit(); }

  info() {
    const v = (this._W || 600) / 3;
    return {
      T:      (1 / this._f1).toFixed(2),
      lambda: (v / this._f1).toFixed(0),
      v:      v.toFixed(0),
      f1:     this._f1
    };
  }

  /* ── анимационный цикл ── */

  _tick(ts) {
    if (!this._paused) {
      if (this._last !== null) {
        const dt = Math.min((ts - this._last) / 1000, 0.05) * this._speed;
        this._t += dt;
        if (this._mode === 'doppler') this._dopStep(dt);
        if (window.LabFX) LabFX.particles.update(dt);
        /* beats: play tick at envelope peaks (throttle to beat period) */
        if (this._mode === 'beats') {
          const fBeat = Math.abs(this._beatsF1 - this._beatsF2);
          if (fBeat > 0) {
            const TBeat = 1 / fBeat;
            const beatPhase = this._t % TBeat;
            if (!this._lastBeatTick || this._t - this._lastBeatTick >= TBeat * 0.95) {
              if (beatPhase < dt * this._speed + 0.02) {
                if (window.LabFX) LabFX.sound.play('tick', { pitch: 0.5, volume: 0.15 });
                this._lastBeatTick = this._t;
              }
            }
          }
        }
      }
      this._last = ts;
      this._raf  = requestAnimationFrame(t => this._tick(t));
    } else {
      this._raf = null;
    }
    this.draw();
    this._emit();
  }

  /* ── главный draw ── */

  draw() {
    const { _ctx: ctx, _W: W, _H: H, _dpr: dpr } = this;
    if (!W || !H) return;
    /* сбрасываем трансформ + заливаем фон */
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = WavesSim.BG;
    ctx.fillRect(0, 0, W, H);
    if      (this._mode === 'transverse')    this._transvDraw(ctx, W, H);
    else if (this._mode === 'longitudinal')  this._longDraw(ctx, W, H);
    else if (this._mode === 'superposition') this._superDraw(ctx, W, H);
    else if (this._mode === 'standing')      this._standDraw(ctx, W, H);
    else if (this._mode === 'doppler')       this._dopplerDraw(ctx, W, H);
    else if (this._mode === 'beats')         this._beatsDraw(ctx, W, H);
    else if (this._mode === 'spectrum')      this._spectrumDraw(ctx, W, H);
    if (window.LabFX) LabFX.particles.draw(ctx);
  }

  /* ══════════════════════════════════════
     ПОПЕРЕЧНАЯ ВОЛНА
  ══════════════════════════════════════ */
  _transvDraw(ctx, W, H) {
    const PL = 48, PR = 20, PT = 50, PB = 48;
    const cw = W - PL - PR;
    const ch = H - PT - PB;
    const cy = PT + ch / 2;

    this._grid(ctx, PL, PR, PT, PB, W, H);
    this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);

    const A   = Math.max(4, Math.min(this._A1, ch / 2 - 8));
    const v   = cw / 3;
    const lam = v / this._f1;
    const k   = (2 * Math.PI) / lam;
    const om  = 2 * Math.PI * this._f1;
    const t   = this._t;
    const phi = this._phi1;
    const y   = x => A * Math.sin(om * t - k * (x - PL) + phi);

    /* волновая кривая */
    const _drawTransvWave = () => {
      ctx.strokeStyle = WavesSim.V;
      ctx.lineWidth   = 2.5;
      ctx.beginPath();
      for (let x = PL; x <= PL + cw; x += 1) {
        const py = cy + y(x);
        x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
      }
      ctx.stroke();
    };
    if (window.LabFX) {
      LabFX.glow.drawGlow(ctx, _drawTransvWave, { color: WavesSim.V, intensity: 5 });
    } else {
      ctx.save();
      ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 16;
      _drawTransvWave();
      ctx.restore();
    }

    /* частицы */
    const step = Math.max(12, Math.floor(lam / 10));
    for (let x = PL + step * 0.5; x < PL + cw; x += step) {
      const py   = cy + y(x);
      const norm = Math.abs(y(x)) / (A || 1);
      ctx.beginPath(); ctx.moveTo(x, cy); ctx.lineTo(x, py);
      ctx.strokeStyle = 'rgba(155,93,229,0.2)'; ctx.lineWidth = 1; ctx.stroke();
      ctx.save();
      ctx.shadowColor = WavesSim.V; ctx.shadowBlur = 8;
      ctx.beginPath(); ctx.arc(x, py, 4, 0, 6.28);
      ctx.fillStyle = `rgba(155,93,229,${(0.4 + 0.6 * norm).toFixed(2)})`; ctx.fill();
      ctx.restore();
    }

    /* выделенная частица */
    const hx = PL + Math.min(lam * 0.5, cw * 0.22);
    const hy = cy + y(hx);
    ctx.save();
    ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
    ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.28);
    ctx.fillStyle = WavesSim.G; ctx.fill();
    ctx.restore();
    ctx.save();
    ctx.setLineDash([3, 4]);
    ctx.strokeStyle = 'rgba(255,209,102,0.22)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(hx, cy - A); ctx.lineTo(hx, cy + A); ctx.stroke();
    ctx.restore();

    /* аннотация длины волны */
    const ld = Math.min(lam, cw - 16);
    if (ld > 36) {
      const ay = cy + A + 26;
      ctx.save();
      ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.moveTo(PL + 8, ay); ctx.lineTo(PL + 8 + ld, ay);
      ctx.moveTo(PL + 13, ay - 4); ctx.lineTo(PL + 8, ay); ctx.lineTo(PL + 13, ay + 4);
      ctx.moveTo(PL + 8 + ld - 5, ay - 4); ctx.lineTo(PL + 8 + ld, ay); ctx.lineTo(PL + 8 + ld - 5, ay + 4);
      ctx.stroke();
      ctx.fillStyle = WavesSim.C; ctx.textAlign = 'center';
      ctx.font = "700 10px 'Manrope',sans-serif";
      ctx.fillText('\u03bb = ' + ld.toFixed(0), PL + 8 + ld / 2, ay - 5);
      ctx.restore();
    }

    /* аннотация амплитуды */
    if (A > 16) {
      const ax = PL - 20;
      ctx.save();
      ctx.strokeStyle = 'rgba(241,91,181,0.7)'; ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.moveTo(ax, cy); ctx.lineTo(ax, cy - A);
      ctx.moveTo(ax - 4, cy - A + 5); ctx.lineTo(ax, cy - A); ctx.lineTo(ax + 4, cy - A + 5);
      ctx.moveTo(ax - 3, cy - 3);     ctx.lineTo(ax, cy);     ctx.lineTo(ax + 3, cy - 3);
      ctx.stroke();
      ctx.fillStyle = WavesSim.P; ctx.textAlign = 'center';
      ctx.font = "700 10px 'Manrope',sans-serif";
      ctx.save(); ctx.translate(ax - 12, cy - A / 2); ctx.rotate(-Math.PI / 2);
      ctx.fillText('A', 0, 0); ctx.restore();
      ctx.restore();
    }

    /* подпись */
    ctx.fillStyle = 'rgba(255,255,255,0.28)';
    ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
    ctx.fillText('y = A sin(\u03c9t \u2212 kx + \u03c6)', PL, PT - 14);
  }

  /* ══════════════════════════════════════
     ПРОДОЛЬНАЯ ВОЛНА
  ══════════════════════════════════════ */
  _longDraw(ctx, W, H) {
    const PL = 24, PR = 24, PT = 50, PB = 60;
    const cw = W - PL - PR;
    const ch = H - PT - PB;

    const nRows = 5;
    const rowH  = ch / nRows;
    const nPart = Math.max(20, Math.floor(cw / 10));
    const dx0   = cw / nPart;

    const v   = cw / 3;
    const lam = v / this._f1;
    const k   = (2 * Math.PI) / lam;
    const om  = 2 * Math.PI * this._f1;
    const A   = Math.min(this._A1 * 0.5, lam / 4, rowH * 0.36);
    const t   = this._t;
    const phi = this._phi1;

    /* ряды частиц */
    for (let row = 0; row < nRows; row++) {
      const cy = PT + rowH * (row + 0.5);
      for (let i = 0; i < nPart; i++) {
        const x0    = PL + (i + 0.5) * dx0;
        const phase = om * t - k * (x0 - PL) + phi;
        const disp  = A * Math.sin(phase);
        const xd    = Math.max(PL + 1, Math.min(PL + cw - 1, x0 + disp));
        const dens  = 1 / Math.max(0.15, 1 + (-A * k * Math.cos(phase)));
        const alpha = Math.max(0.1, Math.min(0.95, dens * 0.55));
        ctx.beginPath(); ctx.arc(xd, cy, 3, 0, 6.28);
        ctx.fillStyle = `rgba(155,93,229,${alpha.toFixed(2)})`; ctx.fill();
      }
    }

    /* график давления */
    const pTop = PT + ch + 10;
    const pH   = H - pTop - 8;
    if (pH > 20) {
      const axY = pTop + pH / 2;
      ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1;
      ctx.setLineDash([4, 3]);
      ctx.beginPath(); ctx.moveTo(PL, axY); ctx.lineTo(PL + cw, axY); ctx.stroke();
      ctx.setLineDash([]);
      ctx.save();
      ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 8;
      ctx.strokeStyle = WavesSim.C; ctx.lineWidth = 2;
      ctx.beginPath();
      for (let x = PL; x <= PL + cw; x += 1) {
        const py = axY - Math.cos(om * t - k * (x - PL) + phi) * pH * 0.4;
        x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
      }
      ctx.stroke(); ctx.restore();
      ctx.fillStyle = WavesSim.C; ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
      ctx.fillText('P(x,t)', PL + 2, pTop + 11);
    }

    ctx.fillStyle = 'rgba(255,255,255,0.28)';
    ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
    ctx.fillText('Продольная волна', PL, PT - 16);
  }

  /* ══════════════════════════════════════
     СУПЕРПОЗИЦИЯ
  ══════════════════════════════════════ */
  _superDraw(ctx, W, H) {
    const PL = 48, PR = 20, PT = 70, PB = 48;
    const cw = W - PL - PR;
    const ch = H - PT - PB;
    const cy = PT + ch / 2;

    this._grid(ctx, PL, PR, PT, PB, W, H);
    this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);

    const v  = cw / 3;
    const t  = this._t;

    const mk = (f, A) => {
      const lam = v / f, k = (2 * Math.PI) / lam, om = 2 * Math.PI * f;
      const amp = Math.max(4, Math.min(A, ch / 2 - 8));
      return { k, om, amp };
    };

    const w1  = mk(this._f1, this._A1);
    const w2  = mk(this._f2, this._A2);
    const y1  = x => w1.amp * Math.sin(w1.om * t - w1.k * (x - PL) + this._phi1);
    const y2  = x => w2.amp * Math.sin(w2.om * t - w2.k * (x - PL) + this._phi2);
    const yR  = x => y1(x) + y2(x);

    this._waveLine(ctx, PL, cw, cy, y1, WavesSim.V, 1.5, 0.45, false);
    this._waveLine(ctx, PL, cw, cy, y2, WavesSim.C, 1.5, 0.45, false);
    this._waveLine(ctx, PL, cw, cy, yR, WavesSim.P, 2.8, 1.0,  true);

    /* легенда */
    const items = [
      { c: WavesSim.V, txt: 'y\u2081 = A\u2081 sin(\u03c9\u2081t \u2212 k\u2081x + \u03c6\u2081)' },
      { c: WavesSim.C, txt: 'y\u2082 = A\u2082 sin(\u03c9\u2082t \u2212 k\u2082x + \u03c6\u2082)' },
      { c: WavesSim.P, txt: 'y = y\u2081 + y\u2082' },
    ];
    ctx.font = "600 9px 'Manrope',sans-serif";
    items.forEach((it, i) => {
      const lx = PL + 6, ly = PT - 56 + i * 18;
      ctx.save();
      ctx.shadowColor = it.c; ctx.shadowBlur = 8;
      ctx.fillStyle = it.c;
      ctx.beginPath(); ctx.arc(lx + 4, ly + 4, 3.5, 0, 6.28); ctx.fill();
      ctx.restore();
      ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left';
      ctx.fillText(it.txt, lx + 13, ly + 8);
    });
  }

  /* ══════════════════════════════════════
     СТОЯЧАЯ ВОЛНА
  ══════════════════════════════════════ */
  _standDraw(ctx, W, H) {
    const PL = 48, PR = 20, PT = 50, PB = 48;
    const cw = W - PL - PR;
    const ch = H - PT - PB;
    const cy = PT + ch / 2;

    this._grid(ctx, PL, PR, PT, PB, W, H);
    this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);

    const n   = this._n;
    const k   = (n * Math.PI) / cw;
    const om  = 2 * Math.PI * this._f1;
    const A   = Math.max(4, Math.min(this._A1, ch / 2 - 10));
    const t   = this._t;

    /* прямая и обратная (тусклые) */
    this._waveLine(ctx, PL, cw, cy, x =>  A * Math.sin(om * t - k * (x - PL)),         WavesSim.V, 1.0, 0.25, false);
    this._waveLine(ctx, PL, cw, cy, x =>  A * Math.sin(om * t + k * (x - PL) + Math.PI), WavesSim.C, 1.0, 0.25, false);

    /* огибающая */
    ctx.save();
    ctx.globalAlpha = 0.12;
    ctx.fillStyle = WavesSim.V;
    ctx.beginPath(); ctx.moveTo(PL, cy);
    for (let x = PL; x <= PL + cw; x++) ctx.lineTo(x, cy - 2 * A * Math.abs(Math.sin(k * (x - PL))));
    for (let x = PL + cw; x >= PL; x--) ctx.lineTo(x, cy + 2 * A * Math.abs(Math.sin(k * (x - PL))));
    ctx.closePath(); ctx.fill(); ctx.restore();

    /* стоячая волна */
    const cosT = Math.cos(om * t + this._phi1);
    this._waveLine(ctx, PL, cw, cy, x => 2 * A * Math.sin(k * (x - PL)) * cosT, WavesSim.G, 2.8, 1.0, true);

    /* узлы (cyan) */
    ctx.save(); ctx.shadowColor = WavesSim.C; ctx.shadowBlur = 10; ctx.fillStyle = WavesSim.C;
    for (let m = 0; m <= n; m++) {
      ctx.beginPath(); ctx.arc(PL + m * cw / n, cy, 5, 0, 6.28); ctx.fill();
    }
    ctx.restore();

    /* пучности (pink) */
    ctx.save(); ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; ctx.fillStyle = WavesSim.P;
    for (let m = 0; m < n; m++) {
      const ax = PL + (m + 0.5) * cw / n;
      const ay = cy + 2 * A * Math.sin(k * (ax - PL)) * cosT;
      ctx.beginPath(); ctx.arc(ax, ay, 5, 0, 6.28); ctx.fill();
    }
    ctx.restore();

    /* легенда */
    const lx = W - PR - 128, ly = PT - 20;
    ctx.font = "600 9px 'Manrope',sans-serif";
    [{ c: WavesSim.C, t: 'Узел (y\u22610)', dy: 0 }, { c: WavesSim.P, t: 'Пучность', dy: 16 }].forEach(r => {
      ctx.save(); ctx.shadowColor = r.c; ctx.shadowBlur = 8; ctx.fillStyle = r.c;
      ctx.beginPath(); ctx.arc(lx + 5, ly + r.dy + 5, 4, 0, 6.28); ctx.fill(); ctx.restore();
      ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'left';
      ctx.fillText(r.t, lx + 14, ly + r.dy + 9);
    });

    ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
    ctx.fillText('n = ' + n + '   \u03bb = 2L/' + n, PL, PT - 14);
  }

  /* ══════════════════════════════════════
     ЭФФЕКТ ДОПЛЕРА
  ══════════════════════════════════════ */

  _dopInit() {
    const W = this._W || 600, H = this._H || 400;
    this._dopSrcX = W * 0.3; this._dopSrcY = H * 0.5;
    this._dopObsX = W * 0.75; this._dopObsY = H * 0.5;
    this._dopRings = [];
    this._dopLastEmit = 0;
    this._dopDir = 1;
  }

  _dopStep(dt) {
    const W = this._W || 600, H = this._H || 400;
    /* speed in px/s: c_px ~= W*0.55 so Mach 1 is full screen width */
    const c_px = W * 0.55;
    const vsPx = this._dopVs * c_px;

    /* move source horizontally, bounce at margins */
    if (!this._dopDrag || this._dopDrag !== 'src') {
      this._dopSrcX += this._dopDir * vsPx * dt;
      if (this._dopSrcX > W - 30) { this._dopSrcX = W - 30; this._dopDir = -1; }
      if (this._dopSrcX < 30)     { this._dopSrcX = 30;     this._dopDir = 1;  }
    }
    this._dopSrcVelX = this._dopDir * vsPx;
    this._dopSrcVelY = 0;

    /* emit rings at source frequency f0 = _f1 */
    const f0 = Math.max(0.5, this._f1);
    this._dopLastEmit += dt;
    const emitInterval = 1 / f0;
    while (this._dopLastEmit >= emitInterval) {
      this._dopLastEmit -= emitInterval;
      this._dopRings.push({ x: this._dopSrcX, y: this._dopSrcY, r: 0, age: 0 });
    }

    /* expand rings at c_px */
    const maxR = Math.sqrt(W * W + H * H);
    this._dopRings = this._dopRings.filter(ring => {
      ring.r   += c_px * dt;
      ring.age += dt;
      return ring.r < maxR;
    });

    /* LabFX: Mach shock particles when vs >= c */
    const mach = vsPx / c_px;
    if (window.LabFX) {
      if (mach >= 1.0 && !this._dopWasMach) {
        this._dopWasMach = true;
        LabFX.sound.play('spark', { volume: 0.4 });
        LabFX.particles.emit({
          ctx: this._ctx, x: this._dopSrcX, y: this._dopSrcY,
          count: 20, color: '#FF6B35', speed: 120,
          spread: Math.PI * 2, angle: 0, gravity: 80,
          life: 600, shape: 'spark', glow: true, size: 3,
        });
      } else if (mach < 1.0) {
        this._dopWasMach = false;
      }

      /* haptic while dragging src (throttle 100ms) */
      if (this._dopDrag === 'src') {
        const now2 = performance.now();
        if (!this._dopHapticLast || now2 - this._dopHapticLast >= 100) {
          LabFX.haptic(5);
          this._dopHapticLast = now2;
        }
      }
    }
  }

  _dopplerDraw(ctx, W, H) {
    if (!this._dopSrcX) this._dopInit();

    const c_px = W * 0.55;
    const vs   = this._dopVs * c_px;          /* px/s */
    const f0   = Math.max(0.5, this._f1);

    /* observed frequency (source moving toward/away observer) */
    const dx = this._dopObsX - this._dopSrcX;
    const dy = this._dopObsY - this._dopSrcY;
    const dist = Math.sqrt(dx * dx + dy * dy) || 1;
    const cosAngle = dx / dist;
    /* projection of source velocity onto source→observer */
    const vsProj = this._dopDir * vs * cosAngle; /* +: toward obs */
    const fObs   = f0 * c_px / Math.max(1, c_px - vsProj);
    const mach   = vs / c_px;

    /* draw rings */
    const ringAlpha = 0.55;
    ctx.save();
    ctx.strokeStyle = WavesSim.C;
    ctx.lineWidth   = 1.5;
    this._dopRings.forEach(ring => {
      const a = Math.max(0, ringAlpha * (1 - ring.age * f0 * 0.5));
      if (a < 0.02) return;
      ctx.globalAlpha = a;
      ctx.beginPath();
      ctx.arc(ring.x, ring.y, ring.r, 0, Math.PI * 2);
      ctx.stroke();
    });
    ctx.restore();

    /* Mach cone if vs >= c_px */
    if (mach >= 1.0) {
      const sinTheta = Math.min(1, c_px / vs);
      const theta    = Math.asin(sinTheta);
      const coneLen  = W * 0.9;
      const sx = this._dopSrcX, sy = this._dopSrcY;
      const dir = this._dopDir;
      ctx.save();
      ctx.globalAlpha = 0.35;
      ctx.fillStyle   = WavesSim.P;
      ctx.beginPath();
      ctx.moveTo(sx, sy);
      ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
                 sy - coneLen * Math.sin(theta));
      ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
                 sy + coneLen * Math.sin(theta));
      ctx.closePath(); ctx.fill();
      ctx.restore();
      ctx.save();
      ctx.strokeStyle = WavesSim.P; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.7;
      ctx.setLineDash([6, 4]);
      ctx.beginPath();
      ctx.moveTo(sx, sy);
      ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
                 sy - coneLen * Math.sin(theta));
      ctx.moveTo(sx, sy);
      ctx.lineTo(sx - dir * coneLen * Math.cos(theta),
                 sy + coneLen * Math.sin(theta));
      ctx.stroke();
      ctx.restore();
    }

    /* source dot */
    ctx.save();
    ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18;
    ctx.fillStyle   = WavesSim.G;
    ctx.beginPath(); ctx.arc(this._dopSrcX, this._dopSrcY, 9, 0, Math.PI * 2); ctx.fill();
    ctx.restore();
    ctx.fillStyle   = WavesSim.BG;
    ctx.font        = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('S', this._dopSrcX, this._dopSrcY);
    ctx.textBaseline = 'alphabetic';

    /* observer dot */
    ctx.save();
    ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 14;
    ctx.fillStyle   = WavesSim.P;
    ctx.beginPath(); ctx.arc(this._dopObsX, this._dopObsY, 7, 0, Math.PI * 2); ctx.fill();
    ctx.restore();
    ctx.fillStyle = WavesSim.BG;
    ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText('O', this._dopObsX, this._dopObsY);
    ctx.textBaseline = 'alphabetic';

    /* HUD */
    const hudX = 14, hudY = 20;
    ctx.fillStyle = 'rgba(13,13,26,0.72)';
    ctx.beginPath(); ctx.roundRect(hudX, hudY, 178, 72, 8); ctx.fill();
    ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left';
    const rows = [
      { c: WavesSim.G,  t: 'f₀ = ' + f0.toFixed(1) + ' Гц' },
      { c: WavesSim.C,  t: 'fᵒᵇˢ = ' + fObs.toFixed(1) + ' Гц' },
      { c: WavesSim.P,  t: 'Mach = ' + mach.toFixed(2) + (mach >= 1 ? '  [удар. волна]' : '') },
    ];
    rows.forEach((r, i) => {
      ctx.fillStyle = r.c;
      ctx.fillText(r.t, hudX + 10, hudY + 18 + i * 18);
    });

    /* drag hint */
    ctx.fillStyle = 'rgba(255,255,255,0.22)';
    ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
    ctx.fillText('Перетащи S (источник) или O (наблюдатель)', W / 2, H - 10);
  }

  /* ══════════════════════════════════════
     БИЕНИЯ
  ══════════════════════════════════════ */

  _beatsDraw(ctx, W, H) {
    const PL = 48, PR = 20, PT = 60, PB = 40;
    const cw = W - PL - PR;
    const ch = H - PT - PB;
    const cy = PT + ch / 2;

    this._grid(ctx, PL, PR, PT, PB, W, H);
    this._axisLine(ctx, PL, PR, PT, PB, W, H, cy);

    const f1   = this._beatsF1;
    const f2   = this._beatsF2;
    const fBeat = Math.abs(f1 - f2);
    const fAvg  = (f1 + f2) / 2;
    const TBeat = fBeat > 0 ? 1 / fBeat : Infinity;

    /* draw time window that spans ~3 beat periods (or 0.1s if no beat) */
    const winS  = TBeat < Infinity ? Math.min(TBeat * 3, 2) : 0.12;
    const tOff  = this._t % (winS > 0 ? winS : 1); /* scroll slowly */
    const A     = Math.max(4, Math.min(ch / 2 - 4, 60));

    /* sum waveform */
    ctx.save();
    ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12;
    ctx.strokeStyle = WavesSim.P; ctx.lineWidth   = 2;
    ctx.beginPath();
    for (let px = 0; px <= cw; px++) {
      const t_s = (px / cw) * winS + tOff;
      const y   = A * Math.cos(2 * Math.PI * f1 * t_s) +
                  A * Math.cos(2 * Math.PI * f2 * t_s);
      const py  = cy - y / 2;  /* /2 because sum can reach 2A */
      px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
    }
    ctx.stroke(); ctx.restore();

    /* envelope */
    ctx.save();
    ctx.strokeStyle = WavesSim.G; ctx.lineWidth = 1.4; ctx.globalAlpha = 0.6;
    ctx.setLineDash([6, 4]);
    for (const sign of [1, -1]) {
      ctx.beginPath();
      for (let px = 0; px <= cw; px++) {
        const t_s = (px / cw) * winS + tOff;
        const env = 2 * A * Math.abs(Math.cos(Math.PI * fBeat * t_s));
        const py  = cy - sign * env / 2;
        px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
      }
      ctx.stroke();
    }
    ctx.restore();

    /* individual waves (dimmed) */
    const drawSingle = (f, color) => {
      ctx.save();
      ctx.globalAlpha = 0.3; ctx.strokeStyle = color; ctx.lineWidth = 1;
      ctx.beginPath();
      for (let px = 0; px <= cw; px++) {
        const t_s = (px / cw) * winS + tOff;
        const py  = cy - A * Math.cos(2 * Math.PI * f * t_s);
        px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py);
      }
      ctx.stroke(); ctx.restore();
    };
    drawSingle(f1, WavesSim.V);
    drawSingle(f2, WavesSim.C);

    /* HUD */
    ctx.fillStyle = 'rgba(13,13,26,0.72)';
    ctx.beginPath(); ctx.roundRect(PL + 6, PT - 52, 220, 48, 8); ctx.fill();
    ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left';
    const hudRows = [
      { c: WavesSim.V, t: 'f₁ = ' + f1.toFixed(1) + ' Гц' },
      { c: WavesSim.C, t: 'f₂ = ' + f2.toFixed(1) + ' Гц' },
      { c: WavesSim.G, t: 'fбиет = ' + fBeat.toFixed(2) + ' Гц    Tбиет = ' + (TBeat < Infinity ? TBeat.toFixed(3) : '∞') + ' с' },
    ];
    hudRows.forEach((r, i) => {
      ctx.fillStyle = r.c;
      ctx.fillText(r.t, PL + 16, PT - 36 + i * 16);
    });

    ctx.fillStyle = 'rgba(255,255,255,0.28)';
    ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
    ctx.fillText('Биения: fбиет = |f₁ − f₂|', PL, H - 8);
  }

  /* ══════════════════════════════════════
     СПЕКТР (ДПФ)
  ══════════════════════════════════════ */

  _dft(signal) {
    /* Real-valued DFT, returns magnitude array of length N/2 */
    const N   = signal.length;
    const half = Math.floor(N / 2);
    const mag  = new Float32Array(half);
    for (let k = 0; k < half; k++) {
      let re = 0, im = 0;
      const angle = (2 * Math.PI * k) / N;
      for (let n = 0; n < N; n++) {
        re += signal[n] * Math.cos(angle * n);
        im -= signal[n] * Math.sin(angle * n);
      }
      mag[k] = Math.sqrt(re * re + im * im) / N;
    }
    return mag;
  }

  _spectrumDraw(ctx, W, H) {
    const PL = 48, PR = 20, PT = 40, PB = 60;
    const cw = W - PL - PR;
    const ch = H - PT - PB;

    /* build signal from components */
    const N  = 256;
    const fs = 100;          /* sample rate Hz */
    const signal = new Float32Array(N);
    const comps  = this._specComponents;
    for (let n = 0; n < N; n++) {
      let val = 0;
      for (const c of comps) val += (c.A / 90) * Math.cos(2 * Math.PI * c.f * n / fs + this._t);
      signal[n] = val;
    }

    /* DFT */
    const mag  = this._dft(signal);
    const half = mag.length;
    const df   = fs / N;     /* Hz per bin */
    const maxF = fs / 2;     /* Nyquist */

    /* find max for normalisation */
    let maxMag = 0;
    for (let k = 0; k < half; k++) if (mag[k] > maxMag) maxMag = mag[k];
    if (maxMag < 1e-9) maxMag = 1;

    /* axes */
    this._axisLine(ctx, PL, PR, PT, PB, W, H, PT + ch);
    ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
    ctx.beginPath();
    for (let gx = PL; gx <= PL + cw; gx += 40) { ctx.moveTo(gx, PT); ctx.lineTo(gx, PT + ch); }
    for (let gy = PT; gy <= PT + ch; gy += 28)  { ctx.moveTo(PL, gy); ctx.lineTo(PL + cw, gy); }
    ctx.stroke();

    /* frequency axis labels */
    ctx.fillStyle = 'rgba(255,255,255,0.3)';
    ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
    const nLabels = Math.min(10, Math.floor(cw / 40));
    for (let i = 0; i <= nLabels; i++) {
      const f = (maxF * i) / nLabels;
      const x = PL + cw * i / nLabels;
      ctx.fillText(f.toFixed(0) + 'Hz', x, PT + ch + 14);
    }
    ctx.fillText('Частота', PL + cw / 2, H - 4);
    ctx.save(); ctx.translate(PL - 32, PT + ch / 2); ctx.rotate(-Math.PI / 2);
    ctx.fillText('Амплитуда', 0, 0); ctx.restore();

    /* bars */
    const barW = Math.max(1, cw / half - 1);
    for (let k = 0; k < half; k++) {
      const norm  = mag[k] / maxMag;
      const bH    = norm * ch;
      const bx    = PL + k * (cw / half);
      const color = norm > 0.5 ? WavesSim.G : WavesSim.V;
      ctx.save();
      if (norm > 0.5) { ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 10; }
      ctx.fillStyle = color;
      ctx.globalAlpha = 0.3 + norm * 0.7;
      ctx.fillRect(bx, PT + ch - bH, barW, bH);
      ctx.restore();
    }

    /* label peaks — bins where this bin's magnitude > neighbors & > 5% */
    const fmtF = f => f.toFixed(1) + 'Hz';
    ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center';
    for (let k = 1; k < half - 1; k++) {
      if (mag[k] > mag[k - 1] && mag[k] > mag[k + 1] && mag[k] / maxMag > 0.05) {
        const bx = PL + k * (cw / half) + barW / 2;
        const bH = (mag[k] / maxMag) * ch;
        ctx.save();
        ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 6;
        ctx.fillStyle   = WavesSim.G;
        ctx.fillText(fmtF(k * df), bx, PT + ch - bH - 5);
        ctx.restore();
      }
    }

    /* components list */
    const listX = PL + 6, listY = PT + 6;
    ctx.fillStyle = 'rgba(13,13,26,0.7)';
    ctx.beginPath();
    ctx.roundRect(listX, listY, 140, Math.min(comps.length * 16 + 8, ch - 12), 6);
    ctx.fill();
    ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left';
    comps.forEach((c, i) => {
      ctx.fillStyle = i % 2 === 0 ? WavesSim.V : WavesSim.C;
      ctx.fillText('f=' + c.f.toFixed(0) + 'Hz  A=' + c.A, listX + 8, listY + 14 + i * 16);
    });

    ctx.fillStyle = 'rgba(255,255,255,0.28)';
    ctx.font = WavesSim.FONT; ctx.textAlign = 'left';
    ctx.fillText('ДПФ: N=' + N + ', fs=' + fs + 'Hz', PL, PT - 12);
  }

  /* ══════════════════════════════════════
     ВСПОМОГАТЕЛЬНЫЕ
  ══════════════════════════════════════ */

  _waveLine(ctx, PL, cw, cy, fn, color, lw, alpha, glow) {
    const drawFn = () => {
      ctx.strokeStyle = color; ctx.lineWidth = lw;
      ctx.beginPath();
      for (let x = PL; x <= PL + cw; x += 1) {
        const py = cy + fn(x);
        x === PL ? ctx.moveTo(x, py) : ctx.lineTo(x, py);
      }
      ctx.stroke();
    };
    ctx.save();
    ctx.globalAlpha = alpha;
    if (glow && window.LabFX) {
      LabFX.glow.drawGlow(ctx, drawFn, { color, intensity: 5 });
    } else {
      if (glow) { ctx.shadowColor = color; ctx.shadowBlur = 16; }
      drawFn();
    }
    ctx.restore();
  }

  _grid(ctx, PL, PR, PT, PB, W, H) {
    ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1;
    ctx.beginPath();
    for (let y = PT; y <= H - PB; y += 28) { ctx.moveTo(PL, y); ctx.lineTo(W - PR, y); }
    for (let x = PL; x <= W - PR; x += 40) { ctx.moveTo(x, PT); ctx.lineTo(x, H - PB); }
    ctx.stroke();
  }

  _axisLine(ctx, PL, PR, PT, PB, W, H, cy) {
    ctx.save();
    ctx.setLineDash([6, 4]);
    ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(PL, cy); ctx.lineTo(W - PR, cy); ctx.stroke();
    ctx.restore();
    ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(PL, PT - 6); ctx.lineTo(PL, H - PB);
    ctx.moveTo(PL, H - PB); ctx.lineTo(W - PR + 6, H - PB);
    ctx.stroke();
    ctx.fillStyle = 'rgba(255,255,255,0.22)';
    ctx.font = "600 9px 'Manrope',sans-serif";
    ctx.textAlign = 'right';  ctx.fillText('y', PL - 4, PT);
    ctx.textAlign = 'left';   ctx.fillText('x', W - PR + 8, H - PB + 4);
  }

  /* Spectrum: add a component at current _specNewF */
  specAddComponent() {
    const f = this._specNewF;
    const A = 60;
    if (this._specComponents.length < 12) {
      this._specComponents.push({ f, A });
      if (window.LabFX) {
        LabFX.sound.play('chime', { pitch: Math.pow(2, f / 12), volume: 0.3 });
        /* emit dust at approximate peak position on spectrum */
        const peakX = this._W ? (this._W * 0.08) + (f / 50) * (this._W * 0.84) : 200;
        const peakY = this._H ? this._H * 0.3 : 100;
        LabFX.particles.emit({
          ctx: this._ctx, x: peakX, y: peakY,
          count: 8, color: '#FFD166', speed: 30,
          spread: Math.PI, angle: -Math.PI / 2, gravity: 50,
          life: 400, shape: 'dust', size: 2,
        });
      }
    }
    this.draw();
  }

  /* Spectrum: clear all components */
  specClear() {
    this._specComponents = [];
    this.draw();
  }

  /* Doppler: attach mouse/touch drag for source and observer */
  dopAttachDrag(canvas) {
    const pos = e => {
      const r = canvas.getBoundingClientRect();
      const src = e.touches ? e.touches[0] : e;
      return { x: (src.clientX - r.left) * (this._W / r.width),
               y: (src.clientY - r.top)  * (this._H / r.height) };
    };
    const hitTest = p => {
      const dS = Math.hypot(p.x - this._dopSrcX, p.y - this._dopSrcY);
      const dO = Math.hypot(p.x - this._dopObsX, p.y - this._dopObsY);
      if (dS < 18) return 'src';
      if (dO < 18) return 'obs';
      return null;
    };
    const start = e => {
      const p = pos(e); this._dopDrag = hitTest(p);
      if (this._dopDrag) e.preventDefault();
    };
    const move = e => {
      if (!this._dopDrag) return;
      e.preventDefault();
      const p = pos(e);
      if (this._dopDrag === 'src') { this._dopSrcX = p.x; this._dopSrcY = p.y; }
      else                         { this._dopObsX = p.x; this._dopObsY = p.y; }
    };
    const end = () => { this._dopDrag = null; };
    canvas.addEventListener('mousedown',  start);
    canvas.addEventListener('mousemove',  move);
    canvas.addEventListener('mouseup',    end);
    canvas.addEventListener('touchstart', start, { passive: false });
    canvas.addEventListener('touchmove',  move,  { passive: false });
    canvas.addEventListener('touchend',   end);
  }

  _emit() { if (this.onUpdate) this.onUpdate(this.info()); }
}

/* ─── lab UI init ─────────────────────────────────── */
  function _openWaves() {
    document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
    document.getElementById('ctrl-waves').style.display = '';
    _simShow('sim-waves');
    _registerSimState('waves', () => wavesSim?.getParams(),
      st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
    if (_embedMode) _startStateEmit('waves');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!wavesSim) {
        wavesSim = new WavesSim(document.getElementById('waves-canvas'));
        wavesSim.onUpdate = _wavesUpdateUI;
      }
      wavesSim.fit();
      wavesSim.reset();
      wavesSim.play();
      _wavesUpdateUI(wavesSim.info());
    }));
  }

  function wavesMode(mode, btn) {
    document.querySelectorAll('.wave-mode-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
    document.getElementById('waves-w2-section').style.display      = mode === 'superposition' ? '' : 'none';
    document.getElementById('waves-n-section').style.display        = mode === 'standing'      ? '' : 'none';
    document.getElementById('waves-doppler-section').style.display  = mode === 'doppler'       ? '' : 'none';
    document.getElementById('waves-beats-section').style.display    = mode === 'beats'         ? '' : 'none';
    document.getElementById('waves-spectrum-section').style.display = mode === 'spectrum'      ? '' : 'none';
    if (wavesSim) {
      wavesSim.setMode(mode);
      if (mode === 'doppler') wavesSim.dopAttachDrag(document.getElementById('waves-canvas'));
    }
  }

  function wavesParam(name, val) {
    const v = parseFloat(val);
    const el = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
    if (name === 'A1')    el('waves-A1-val',    v);
    if (name === 'f1')    el('waves-f1-val',    v.toFixed(1) + ' Гц');
    if (name === 'phi1')  el('waves-phi1-val',  v.toFixed(1));
    if (name === 'A2')    el('waves-A2-val',    v);
    if (name === 'f2')    el('waves-f2-val',    v.toFixed(1) + ' Гц');
    if (name === 'phi2')  el('waves-phi2-val',  v.toFixed(1));
    if (name === 'speed')    el('waves-speed-val',    '\u00d7' + v.toFixed(1));
    if (name === 'dopVs')    el('waves-dopVs-val',    v.toFixed(2) + 'c');
    if (name === 'beatsF1')  el('waves-beatsF1-val',  v.toFixed(0) + ' \u0413\u0446');
    if (name === 'beatsF2')  el('waves-beatsF2-val',  v.toFixed(0) + ' \u0413\u0446');
    if (name === 'specNewF') el('waves-specNewF-val', v.toFixed(0) + ' \u0413\u0446');
    if (wavesSim) wavesSim.setParams({ [name]: v });
  }

  function wavesSpecAdd() {
    if (wavesSim) wavesSim.specAddComponent();
  }

  function wavesSpecClear() {
    if (wavesSim) wavesSim.specClear();
  }

  function wavesN(n, btn) {
    document.querySelectorAll('.wave-n-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
    if (wavesSim) wavesSim.setParams({ n });
  }

  function wavesPreset(name) {
    const presets = {
      constructive: { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 0 },
      destructive:  { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.0, phi2: 3.14 },
      beats:        { A1: 50, f1: 1.0, phi1: 0, A2: 50, f2: 1.3, phi2: 0 },
    };
    const p = presets[name]; if (!p) return;
    document.getElementById('sl-waves-A1').value   = p.A1;
    document.getElementById('sl-waves-f1').value   = p.f1;
    document.getElementById('sl-waves-phi1').value = p.phi1;
    document.getElementById('sl-waves-A2').value   = p.A2;
    document.getElementById('sl-waves-f2').value   = p.f2;
    document.getElementById('sl-waves-phi2').value = p.phi2;
    document.getElementById('waves-A1-val').textContent   = p.A1;
    document.getElementById('waves-f1-val').textContent   = p.f1.toFixed(1) + ' Гц';
    document.getElementById('waves-phi1-val').textContent = p.phi1.toFixed(1);
    document.getElementById('waves-A2-val').textContent   = p.A2;
    document.getElementById('waves-f2-val').textContent   = p.f2.toFixed(1) + ' Гц';
    document.getElementById('waves-phi2-val').textContent = p.phi2.toFixed(1);
    if (wavesSim) wavesSim.setParams({ A1: p.A1, f1: p.f1, phi1: p.phi1, A2: p.A2, f2: p.f2, phi2: p.phi2 });
  }

  function wavesPlayPause() {
    if (!wavesSim) return;
    const btn = document.getElementById('waves-play-btn');
    if (wavesSim._paused) {
      wavesSim.play();
      btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>';
    } else {
      wavesSim.pause();
      btn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
    }
  }

  function _wavesUpdateUI(info) {
    const v = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
    v('wavesbar-T',   info.T);
    v('wavesbar-lam', info.lambda);
    v('wavesbar-v',   info.v);
    v('wavesbar-f',   (+info.f1).toFixed(1));
  }

  /* ── crystal lattice (3D) ── */
