'use strict';
/* ══════════════════════════════════════════════════════════════
   TitrationSim — acid-base titration simulation
   Strong acid (HCl) / weak acid (CH₃COOH) + strong base (NaOH)
   Left 60%: burette + Erlenmeyer flask with indicator colour
   Right 40%: real-time pH vs V(base) titration curve
   Henderson-Hasselbalch for weak acid buffer region
   ══════════════════════════════════════════════════════════════ */

class TitrationSim {

  static PINK   = '#EF476F';
  static VIOLET = '#9B5DE5';
  static CYAN   = '#06D6E0';
  static GREEN  = '#7BF5A4';
  static YELLOW = '#FFD166';
  static BG     = '#0D0D1A';
  static FONT   = "Manrope, system-ui, sans-serif";

  /* ── Constructor ────────────────────────────────────────── */

  constructor(canvas) {
    this.canvas = canvas;
    this.ctx    = canvas.getContext('2d');
    this.W = 0; this.H = 0;

    /* chemistry */
    this.acidConc  = 0.1;               // mol/L
    this.baseConc  = 0.1;               // mol/L
    this.acidVol   = 50;                // mL
    this.acidType  = 'strong';          // 'strong' | 'weak'
    this.indicator = 'phenolphthalein'; // 'phenolphthalein' | 'methyl_orange' | 'litmus'
    this.Ka        = 1.8e-5;           // CH₃COOH dissociation constant

    /* state */
    this.baseAdded  = 0;                // mL of base added
    this._curve     = [];               // [{v, pH}]
    this._drops     = [];               // [{x, y, vy, r}]
    this._splashes  = [];               // [{x, y, vx, vy, r, life}]
    this._ripples   = [];               // [{x, y, radius, life}]
    this._dropAccum = 0;
    this._wave      = 0;

    /* animation */
    this.playing = false;
    this._raf    = null;
    this._lastTs = null;
    this.speed   = 1;

    this.onUpdate = null;

    this._recordPoint();
    new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
  }

  /* ── Geometry ───────────────────────────────────────────── */

  fit() {
    const dpr = window.devicePixelRatio || 1;
    const w = this.canvas.offsetWidth  || 600;
    const h = this.canvas.offsetHeight || 400;
    this.canvas.width  = w * dpr;
    this.canvas.height = h * dpr;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    this.W = w; this.H = h;
  }

  /* ── Public API ─────────────────────────────────────────── */

  getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
  setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
    if (acidConc  !== undefined) this.acidConc  = Math.max(0.05, Math.min(1.0, +acidConc));
    if (baseConc  !== undefined) this.baseConc  = Math.max(0.05, Math.min(1.0, +baseConc));
    if (acidVol   !== undefined) this.acidVol   = Math.max(25, Math.min(100, +acidVol));
    if (indicator !== undefined) this.indicator = indicator;
    if (acidType  !== undefined) this.acidType  = acidType;
    this.reset();
  }

  preset(name) {
    const presets = {
      strong_strong: { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'strong', indicator: 'phenolphthalein' },
      weak_strong:   { acidConc: 0.1, baseConc: 0.1, acidVol: 50, acidType: 'weak',   indicator: 'phenolphthalein' },
      concentrated:  { acidConc: 0.5, baseConc: 0.5, acidVol: 25, acidType: 'strong', indicator: 'methyl_orange'   },
    };
    const p = presets[name] || presets.strong_strong;
    Object.assign(this, p);
    this.reset();
  }

  reset() {
    this.pause();
    this.baseAdded  = 0;
    this._curve     = [];
    this._drops     = [];
    this._splashes  = [];
    this._ripples   = [];
    this._dropAccum = 0;
    this._wave      = 0;
    this._recordPoint();
    this.draw();
    this._emit();
  }

  play() {
    if (this.playing) return;
    this.playing = true;
    this._lastTs = null;
    this._tick();
  }

  pause() {
    this.playing = false;
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  start() { this.play(); }
  stop()  { this.pause(); }

  info() {
    const eqVol = this._eqVolume();
    return {
      pH:        +this._calcPH(this.baseAdded).toFixed(2),
      baseAdded: +this.baseAdded.toFixed(2),
      eqPoint:   +eqVol.toFixed(2),
      indicator: this.indicator,
      acidType:  this.acidType,
    };
  }

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

  /* ── Chemistry ──────────────────────────────────────────── */

  _eqVolume() { return (this.acidConc * this.acidVol) / this.baseConc; }
  _maxVolume() { return this._eqVolume() * 1.5; }

  _calcPH(vBase) {
    const nAcid = this.acidConc * this.acidVol / 1000;
    const nBase = this.baseConc * vBase / 1000;
    const vTotal = (this.acidVol + vBase) / 1000;
    return this.acidType === 'strong'
      ? this._strongPH(nAcid, nBase, vTotal)
      : this._weakPH(nAcid, nBase, vTotal);
  }

  _strongPH(nA, nB, vT) {
    const d = nA - nB;
    if (Math.abs(d) < 1e-10) return 7.0;
    if (d > 0) return -Math.log10(d / vT);
    return 14 + Math.log10(-d / vT);
  }

  _weakPH(nA, nB, vT) {
    const Ka = this.Ka;
    const d  = nA - nB;
    if (d < -1e-10) return 14 + Math.log10(-d / vT);            // excess base
    if (Math.abs(d) < 1e-10) {                                   // equivalence — hydrolysis
      const Kb = 1e-14 / Ka;
      return 14 + Math.log10(Math.sqrt(Kb * (nB / vT)));
    }
    if (nB < 1e-10) {                                            // pure weak acid
      const c = nA / vT;
      const cH = (-Ka + Math.sqrt(Ka * Ka + 4 * Ka * c)) / 2;
      return -Math.log10(cH);
    }
    return -Math.log10(Ka) + Math.log10((nB / vT) / (d / vT));  // Henderson-Hasselbalch
  }

  _recordPoint() {
    this._curve.push({ v: this.baseAdded, pH: this._calcPH(this.baseAdded) });
  }

  /* ── Indicator colour ───────────────────────────────────── */

  _indicatorColor(pH) {
    if (this.indicator === 'phenolphthalein') {
      if (pH < 8.2)  return 'rgba(255,255,255,0.04)';
      if (pH > 10)   return 'rgba(220,20,120,0.60)';
      const t = (pH - 8.2) / 1.8;
      return `rgba(220,${200 - Math.round(180 * t)},${255 - Math.round(135 * t)},${(0.04 + 0.56 * t).toFixed(2)})`;
    }
    if (this.indicator === 'methyl_orange') {
      if (pH < 3.1) return 'rgba(220,40,40,0.50)';
      if (pH > 4.4) return 'rgba(240,210,60,0.35)';
      const t = (pH - 3.1) / 1.3;
      return `rgba(${220 + Math.round(20 * t)},${40 + Math.round(170 * t)},${40 + Math.round(20 * t)},${(0.50 - 0.15 * t).toFixed(2)})`;
    }
    /* litmus */
    if (pH < 5) return 'rgba(220,50,60,0.55)';
    if (pH > 8) return 'rgba(60,80,210,0.55)';
    const t = (pH - 5) / 3;
    return `rgba(${220 - Math.round(160 * t)},${50 + Math.round(30 * t)},${60 + Math.round(150 * t)},0.55)`;
  }

  _liquidRGB(pH) {
    if (this.indicator === 'phenolphthalein') {
      if (pH < 8.2) return [180, 210, 255];
      const t = Math.min(1, (pH - 8.2) / 1.8);
      return [180 + t * 40, 210 - t * 140, 255 - t * 135];
    }
    if (this.indicator === 'methyl_orange') {
      if (pH < 3.1) return [220, 80, 80];
      if (pH > 4.4) return [240, 210, 80];
      const t = (pH - 3.1) / 1.3;
      return [220 + t * 20, 80 + t * 130, 80];
    }
    /* litmus */
    if (pH < 5) return [220, 70, 70];
    if (pH > 8) return [80, 100, 220];
    const t = (pH - 5) / 3;
    return [220 - 140 * t, 70 + 30 * t, 70 + 150 * t];
  }

  _phColor(pH) {
    if (pH < 3)  return TitrationSim.PINK;
    if (pH < 5)  return TitrationSim.YELLOW;
    if (pH < 9)  return TitrationSim.GREEN;
    if (pH < 11) return TitrationSim.CYAN;
    return TitrationSim.VIOLET;
  }

  /* ── Animation loop ─────────────────────────────────────── */

  _tick() {
    if (!this.playing) return;
    this._raf = requestAnimationFrame(ts => {
      if (this._lastTs === null) this._lastTs = ts;
      const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
      this._lastTs = ts;
      const dt = rawDt * this.speed;
      if (window.LabFX) LabFX.particles.update(rawDt);

      this._wave += rawDt * 2.0;

      const maxV = this._maxVolume();
      if (this.baseAdded < maxV) {
        this.baseAdded = Math.min(this.baseAdded + (maxV / 14) * dt, maxV);
        this._recordPoint();
        if (this._curve.length > 600) this._curve.shift();
        this._spawnDrops(dt);
      } else {
        this.pause();
      }

      /* move drops */
      for (const d of this._drops) { d.vy += 480 * dt; d.y += d.vy * dt; }
      const surfY = this.H * 0.72;
      /* spawn splashes when drops hit surface */
      const eqVol = this._eqVolume();
      const wasNearEq = this._fxNearEq;
      this._fxNearEq = Math.abs(this.baseAdded - eqVol) < eqVol * 0.05;
      if (!wasNearEq && this._fxNearEq && window.LabFX) {
        // Equivalence point crossed
        LabFX.sound.play('chime', { pitch: 1.3 });
      }
      for (const d of this._drops) {
        if (d.y >= surfY && !d.hit) {
          d.hit = true;
          const bx = d.x;
          // LabFX: pour sound + splash particles (throttled to ~3/sec)
          if (window.LabFX) {
            const now = performance.now();
            if (!this._fxDropSnd || now - this._fxDropSnd > 330) {
              this._fxDropSnd = now;
              LabFX.sound.play('pour', { volume: 0.4, pitch: 1.2 });
              LabFX.haptic(5);
            }
            const curPH = this._calcPH(this.baseAdded);
            const col = this._indicatorColor(curPH);
            LabFX.particles.emit({ ctx: this.ctx, x: bx, y: surfY, count: 4,
              color: col, speed: 25, spread: 2.2, angle: -Math.PI / 2,
              gravity: 200, life: 600, shape: 'splash' });
          }
          for (let i = 0; i < 3; i++) {
            const a = -Math.PI * 0.5 + (Math.random() - 0.5) * Math.PI;
            const s = 15 + Math.random() * 25;
            this._splashes.push({ x: bx, y: surfY, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 8, r: 1 + Math.random(), life: 1 });
          }
          this._ripples.push({ x: bx, y: surfY, radius: 2, life: 1 });
        }
      }
      this._drops = this._drops.filter(d => d.y < surfY + 4);

      /* animate splashes */
      for (const s of this._splashes) { s.x += s.vx * dt; s.y += s.vy * dt; s.vy += 160 * dt; s.life -= dt * 3.5; }
      this._splashes = this._splashes.filter(s => s.life > 0);
      for (const r of this._ripples) { r.radius += dt * 30; r.life -= dt * 2.2; }
      this._ripples = this._ripples.filter(r => r.life > 0);

      this.draw();
      this._emit();
      if (this.playing) this._tick();
    });
  }

  _spawnDrops(dt) {
    this._dropAccum += dt;
    const interval = 0.18 / Math.max(0.5, this.speed);
    while (this._dropAccum >= interval) {
      this._dropAccum -= interval;
      const simW = this.W * 0.6;
      const bx = simW * 0.42;
      this._drops.push({
        x: bx + (Math.random() - 0.5) * 3,
        y: this.H * 0.38 + 14,
        vy: 10 + Math.random() * 8,
        r: 2.2 + Math.random() * 1.4,
        hit: false,
      });
    }
  }

  /* ═══════════════════════ Rendering ═══════════════════════ */

  draw() {
    const { ctx, W, H } = this;
    if (!W || !H) return;
    const simW = W * 0.6;

    ctx.fillStyle = TitrationSim.BG;
    ctx.fillRect(0, 0, W, H);

    /* dot grid */
    ctx.fillStyle = 'rgba(255,255,255,0.04)';
    for (let x = 0; x < W; x += 28) for (let y = 0; y < H; y += 28) {
      ctx.beginPath(); ctx.arc(x, y, 0.7, 0, Math.PI * 2); ctx.fill();
    }

    /* desk surface behind glassware */
    if (window.ChemVisuals) {
      ChemVisuals.drawDeskBackground(ctx, simW, H, H * 0.90);
    }

    /* divider */
    ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(simW, 16); ctx.lineTo(simW, H - 16); ctx.stroke();

    this._drawStand(ctx, simW);
    this._drawBurette(ctx, simW);
    this._drawFlask(ctx, simW);
    this._drawParticles(ctx);
    this._drawOverlay(ctx);
    this._drawPHCurve(ctx, simW, W, H);

    /* pH strip indicator on the right of flask */
    if (window.ChemVisuals) {
      const pH = this._calcPH(this.baseAdded);
      ChemVisuals.drawPHStrip(ctx, simW * 0.72, H * 0.24, pH);
    }

    if (window.LabFX) LabFX.particles.draw(ctx);
  }

  /* ── Lab stand ──────────────────────────────────────────── */

  _drawStand(ctx, simW) {
    const H = this.H, sx = simW * 0.2;
    const g = ctx.createLinearGradient(sx - 3, 0, sx + 3, 0);
    g.addColorStop(0, 'rgba(120,130,160,0.5)');
    g.addColorStop(0.5, 'rgba(180,190,210,0.7)');
    g.addColorStop(1, 'rgba(100,110,140,0.4)');
    ctx.fillStyle = g;
    ctx.fillRect(sx - 3, H * 0.06, 6, H * 0.84);

    ctx.fillStyle = 'rgba(150,160,190,0.40)';
    ctx.beginPath(); ctx.roundRect(sx - 36, H * 0.90, 72, 7, 3); ctx.fill();

    ctx.fillStyle = 'rgba(140,150,180,0.55)';
    ctx.fillRect(sx - 1, H * 0.12, simW * 0.22 + 2, 5);
  }

  /* ── Burette ────────────────────────────────────────────── */

  _drawBurette(ctx, simW) {
    const H = this.H, FNT = TitrationSim.FONT;
    const bx = simW * 0.42, bT = H * 0.06, bB = H * 0.38, bW = 12;
    const maxV = this._maxVolume();
    const frac = Math.max(0, 1 - this.baseAdded / maxV);

    /* glass tube */
    const gg = ctx.createLinearGradient(bx - bW, 0, bx + bW, 0);
    gg.addColorStop(0, 'rgba(120,170,255,0.18)');
    gg.addColorStop(0.4, 'rgba(160,200,255,0.08)');
    gg.addColorStop(0.6, 'rgba(160,200,255,0.08)');
    gg.addColorStop(1, 'rgba(100,150,240,0.15)');
    ctx.fillStyle = gg;
    ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.fill();

    /* liquid level */
    if (frac > 0.01) {
      const lt = bT + (bB - bT) * (1 - frac) + 4;
      const lg = ctx.createLinearGradient(0, lt, 0, bB);
      lg.addColorStop(0, 'rgba(100,160,255,0.25)');
      lg.addColorStop(1, 'rgba(80,140,240,0.40)');
      ctx.fillStyle = lg;
      ctx.beginPath(); ctx.roundRect(bx - bW + 2, lt, bW * 2 - 4, bB - lt - 4, 3); ctx.fill();
    }

    /* glass outline + highlight */
    ctx.strokeStyle = 'rgba(120,175,255,0.50)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.roundRect(bx - bW, bT, bW * 2, bB - bT, 4); ctx.stroke();
    ctx.strokeStyle = 'rgba(200,225,255,0.25)'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(bx - bW + 3, bT + 8); ctx.lineTo(bx - bW + 3, bB - 8); ctx.stroke();

    /* graduations */
    ctx.strokeStyle = 'rgba(180,210,255,0.30)'; ctx.lineWidth = 0.8;
    ctx.font = `8px ${FNT}`; ctx.fillStyle = 'rgba(180,210,255,0.45)'; ctx.textAlign = 'right';
    for (let i = 0; i <= 10; i++) {
      const y = bT + 6 + (bB - bT - 12) * (i / 10), maj = i % 2 === 0;
      ctx.beginPath(); ctx.moveTo(bx + bW, y); ctx.lineTo(bx + bW + (maj ? 8 : 4), y); ctx.stroke();
      if (maj) ctx.fillText(((i / 10) * maxV).toFixed(0), bx + bW + 22, y + 3);
    }

    /* stopcock + nozzle */
    ctx.fillStyle = 'rgba(180,190,220,0.55)'; ctx.fillRect(bx - 4, bB - 2, 8, 8);
    ctx.fillStyle = 'rgba(140,165,210,0.50)';
    ctx.beginPath();
    ctx.moveTo(bx - 3, bB + 6); ctx.lineTo(bx + 3, bB + 6);
    ctx.lineTo(bx + 1.5, bB + 14); ctx.lineTo(bx - 1.5, bB + 14);
    ctx.closePath(); ctx.fill();

    /* forming drip */
    if (this.playing && this.baseAdded < maxV) {
      const pulse = 0.5 + 0.5 * Math.sin(this._wave * 4);
      const dr = 2.5 + pulse * 1.5;
      ctx.save(); ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
      ctx.fillStyle = 'rgba(100,180,255,0.65)';
      ctx.beginPath(); ctx.arc(bx, bB + 14 + dr, dr, 0, Math.PI * 2); ctx.fill();
      ctx.restore();
    }

    /* labels */
    ctx.fillStyle = 'rgba(180,210,255,0.60)'; ctx.font = `bold 10px ${FNT}`; ctx.textAlign = 'center';
    ctx.fillText('NaOH', bx, bT - 6);
    ctx.fillText(`${this.baseConc} M`, bx, bT - 18);
  }

  /* ── Erlenmeyer flask ───────────────────────────────────── */

  _drawFlask(ctx, simW) {
    const H = this.H, cx = simW * 0.42, pH = this._calcPH(this.baseAdded);
    const fB = H * 0.88, fNT = H * 0.58, fNW = 10, fBW = simW * 0.22;

    const flaskP = () => {
      ctx.beginPath();
      ctx.moveTo(cx - fNW, fNT); ctx.lineTo(cx - fNW, fNT + 16);
      ctx.bezierCurveTo(cx - fNW, fNT + 40, cx - fBW, fB - 30, cx - fBW, fB);
      ctx.lineTo(cx + fBW, fB);
      ctx.bezierCurveTo(cx + fBW, fB - 30, cx + fNW, fNT + 40, cx + fNW, fNT + 16);
      ctx.lineTo(cx + fNW, fNT); ctx.closePath();
    };

    const lY = H * 0.72, [lr, lg, lb] = this._liquidRGB(pH);
    const amp = 2 + (this.playing ? 1.5 : 0);
    const wY = x => lY + Math.sin((x - cx) * 0.08 + this._wave) * amp
                       + Math.sin((x - cx) * 0.15 - this._wave * 1.4) * amp * 0.3;

    /* liquid clipped to flask */
    ctx.save(); flaskP(); ctx.clip();
    ctx.beginPath();
    for (let x = cx - fBW - 2; x <= cx + fBW + 2; x += 2) {
      x === cx - fBW - 2 ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
    }
    ctx.lineTo(cx + fBW + 2, fB + 4); ctx.lineTo(cx - fBW - 2, fB + 4); ctx.closePath();
    const lGrad = ctx.createLinearGradient(0, lY, 0, fB);
    lGrad.addColorStop(0, `rgba(${lr},${lg},${lb},0.30)`);
    lGrad.addColorStop(0.5, `rgba(${lr},${lg},${lb},0.45)`);
    lGrad.addColorStop(1, `rgba(${lr},${lg},${lb},0.55)`);
    ctx.fillStyle = lGrad; ctx.fill();
    ctx.fillStyle = this._indicatorColor(pH); ctx.fill();

    /* surface shimmer */
    ctx.beginPath();
    for (let x = cx - fBW; x <= cx + fBW; x += 2) {
      x === cx - fBW ? ctx.moveTo(x, wY(x)) : ctx.lineTo(x, wY(x));
    }
    ctx.strokeStyle = `rgba(${Math.min(255, lr + 80)},${Math.min(255, lg + 80)},${Math.min(255, lb + 80)},0.45)`;
    ctx.lineWidth = 1.2; ctx.stroke();
    ctx.restore();

    /* glass outline */
    ctx.strokeStyle = 'rgba(120,175,255,0.55)'; ctx.lineWidth = 2; flaskP(); ctx.stroke();

    /* left highlight */
    ctx.save(); ctx.beginPath();
    ctx.moveTo(cx - fNW + 2, fNT + 4); ctx.lineTo(cx - fNW + 2, fNT + 18);
    ctx.bezierCurveTo(cx - fNW + 2, fNT + 42, cx - fBW + 8, fB - 32, cx - fBW + 6, fB - 4);
    const hg = ctx.createLinearGradient(cx - fBW, fNT, cx - fBW, fB);
    hg.addColorStop(0, 'rgba(200,230,255,0.30)'); hg.addColorStop(1, 'rgba(200,230,255,0.03)');
    ctx.strokeStyle = hg; ctx.lineWidth = 3; ctx.stroke(); ctx.restore();

    /* neck rim */
    ctx.strokeStyle = 'rgba(140,185,255,0.60)'; ctx.lineWidth = 2.5;
    ctx.beginPath(); ctx.moveTo(cx - fNW - 4, fNT); ctx.lineTo(cx + fNW + 4, fNT); ctx.stroke();

    /* acid label */
    const label = this.acidType === 'strong' ? 'HCl' : 'CH\u2083COOH';
    ctx.fillStyle = 'rgba(180,210,255,0.55)'; ctx.font = `9px ${TitrationSim.FONT}`; ctx.textAlign = 'center';
    ctx.fillText(`${label} ${this.acidConc} M, ${this.acidVol} mL`, cx, fB + 14);

    /* pH value */
    ctx.font = `bold 14px ${TitrationSim.FONT}`;
    ctx.fillStyle = this._phColor(pH);
    ctx.fillText(`pH ${pH.toFixed(2)}`, cx, fB + 32);
  }

  /* ── Drops / splashes / ripples ─────────────────────────── */

  _drawParticles(ctx) {
    for (const d of this._drops) {
      ctx.save(); ctx.globalAlpha = 0.85;
      ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 8;
      const st = Math.min(2.5, 1 + d.vy * 0.003);
      ctx.beginPath(); ctx.ellipse(d.x, d.y, d.r, d.r * st, 0, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(100,180,255,0.70)'; ctx.fill();
      ctx.fillStyle = 'rgba(220,240,255,0.55)';
      ctx.beginPath(); ctx.arc(d.x - d.r * 0.3, d.y - d.r * 0.4, d.r * 0.3, 0, Math.PI * 2); ctx.fill();
      ctx.restore();
    }
    const [sr, sg, sb] = this._liquidRGB(this._calcPH(this.baseAdded));
    for (const s of this._splashes) {
      ctx.save(); ctx.globalAlpha = s.life * 0.7;
      ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fillStyle = `rgb(${Math.min(255, sr + 60)},${Math.min(255, sg + 60)},${Math.min(255, sb + 60)})`; ctx.fill();
      ctx.restore();
    }
    for (const r of this._ripples) {
      ctx.save(); ctx.globalAlpha = r.life * 0.4;
      ctx.strokeStyle = 'rgba(180,220,255,0.6)'; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.stroke();
      ctx.restore();
    }
  }

  /* ── Stats overlay ──────────────────────────────────────── */

  _drawOverlay(ctx) {
    const pH = this._calcPH(this.baseAdded);
    const eqV = this._eqVolume();
    const bx = 10, by = 10, bw = 150, bh = 78;

    ctx.fillStyle = 'rgba(5,5,20,0.82)';
    ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 7); ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();

    ctx.textAlign = 'left'; ctx.textBaseline = 'top';
    const lh = 16;

    ctx.font = `bold 12px ${TitrationSim.FONT}`;
    ctx.fillStyle = TitrationSim.CYAN;
    ctx.fillText(`pH = ${pH.toFixed(2)}`, bx + 10, by + 8);

    ctx.font = `10px ${TitrationSim.FONT}`;
    ctx.fillStyle = TitrationSim.YELLOW;
    ctx.fillText(`V(NaOH) = ${this.baseAdded.toFixed(1)} mL`, bx + 10, by + 8 + lh);

    ctx.fillStyle = TitrationSim.GREEN;
    ctx.fillText(`V\u044D\u043A\u0432 = ${eqV.toFixed(1)} mL`, bx + 10, by + 8 + lh * 2);

    const names = { phenolphthalein: '\u0424\u0435\u043D\u043E\u043B\u0444\u0442.', methyl_orange: '\u041C\u0435\u0442.\u043E\u0440.', litmus: '\u041B\u0430\u043A\u043C\u0443\u0441' };
    ctx.fillStyle = 'rgba(255,255,255,0.40)';
    ctx.fillText(names[this.indicator] || this.indicator, bx + 10, by + 8 + lh * 3);
  }

  /* ── pH titration curve (right 40%) ─────────────────────── */

  _drawPHCurve(ctx, x0, W, H) {
    const gW = W - x0;
    const pad = { l: 36, r: 12, t: 30, b: 32 };
    const px = x0 + pad.l, py = pad.t;
    const pw = gW - pad.l - pad.r, ph = H - pad.t - pad.b;
    const maxV = this._maxVolume();
    const eqV  = this._eqVolume();
    const FNT  = TitrationSim.FONT;

    /* panel bg */
    ctx.fillStyle = 'rgba(5,5,20,0.85)';
    ctx.fillRect(x0, 0, gW, H);

    /* title */
    ctx.fillStyle = 'rgba(200,220,255,0.65)';
    ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'center';
    ctx.fillText('\u041A\u0440\u0438\u0432\u0430\u044F \u0442\u0438\u0442\u0440\u043E\u0432\u0430\u043D\u0438\u044F', x0 + gW / 2, 14);

    /* grid + y labels */
    ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 0.5;
    ctx.fillStyle = 'rgba(180,210,255,0.35)';
    ctx.font = `9px ${FNT}`; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
    for (let p = 0; p <= 14; p += 2) {
      const yl = py + ph - (p / 14) * ph;
      ctx.beginPath(); ctx.moveTo(px, yl); ctx.lineTo(px + pw, yl); ctx.stroke();
      ctx.fillText(p.toString(), px - 5, yl);
    }

    /* x labels */
    ctx.textAlign = 'center'; ctx.textBaseline = 'top';
    const vs = maxV > 60 ? 20 : maxV > 30 ? 10 : 5;
    for (let v = 0; v <= maxV; v += vs) {
      const xl = px + (v / maxV) * pw;
      ctx.strokeStyle = 'rgba(255,255,255,0.06)';
      ctx.beginPath(); ctx.moveTo(xl, py); ctx.lineTo(xl, py + ph); ctx.stroke();
      ctx.fillText(v.toFixed(0), xl, py + ph + 6);
    }
    ctx.fillStyle = 'rgba(180,210,255,0.50)'; ctx.font = `bold 10px ${FNT}`;
    ctx.fillText('V (mL)', x0 + gW / 2, py + ph + 22);

    /* y-axis label */
    ctx.save();
    ctx.translate(x0 + 10, py + ph / 2);
    ctx.rotate(-Math.PI / 2);
    ctx.fillText('pH', 0, 0);
    ctx.restore();

    /* dashed pH=7 */
    const y7 = py + ph * (1 - 7 / 14);
    ctx.setLineDash([4, 4]);
    ctx.strokeStyle = 'rgba(123,245,164,0.25)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(px, y7); ctx.lineTo(px + pw, y7); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = 'rgba(123,245,164,0.40)'; ctx.font = `9px ${FNT}`;
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText('pH 7', px + pw + 4, y7);

    /* axes */
    ctx.strokeStyle = 'rgba(160,200,255,0.40)'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, py + ph); ctx.lineTo(px + pw, py + ph); ctx.stroke();

    /* curve */
    if (this._curve.length > 1) {
      ctx.strokeStyle = TitrationSim.CYAN; ctx.lineWidth = 2.5;
      ctx.shadowColor = TitrationSim.CYAN; ctx.shadowBlur = 6;
      ctx.beginPath();
      for (let i = 0; i < this._curve.length; i++) {
        const pt = this._curve[i];
        const lx = px + (pt.v / maxV) * pw;
        const ly = py + ph * (1 - Math.max(0, Math.min(14, pt.pH)) / 14);
        i === 0 ? ctx.moveTo(lx, ly) : ctx.lineTo(lx, ly);
      }
      ctx.stroke(); ctx.shadowBlur = 0;

      /* current point dot + tooltip */
      const last = this._curve[this._curve.length - 1];
      const dx = px + (last.v / maxV) * pw;
      const dy = py + ph * (1 - Math.max(0, Math.min(14, last.pH)) / 14);
      ctx.save();
      ctx.shadowColor = '#FFF'; ctx.shadowBlur = 10; ctx.fillStyle = '#FFF';
      ctx.beginPath(); ctx.arc(dx, dy, 4.5, 0, Math.PI * 2); ctx.fill();
      ctx.shadowBlur = 0;

      const tw = 72, th = 30;
      const tx = Math.min(dx + 8, px + pw - tw - 4);
      const ty = Math.max(dy - th - 8, py + 2);
      ctx.fillStyle = 'rgba(0,0,0,0.65)';
      ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 5); ctx.fill();
      ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke();
      ctx.fillStyle = this._phColor(last.pH);
      ctx.font = `bold 11px ${FNT}`; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
      ctx.fillText(`pH ${last.pH.toFixed(2)}`, tx + 6, ty + 5);
      ctx.fillStyle = 'rgba(200,220,255,0.60)'; ctx.font = `9px ${FNT}`;
      ctx.fillText(`${last.v.toFixed(1)} mL`, tx + 6, ty + 18);
      ctx.restore();
    }

    /* equivalence point markers */
    const eqX = px + (eqV / maxV) * pw;
    const eqPH = this._calcPH(eqV);
    const eqY = py + ph * (1 - Math.max(0, Math.min(14, eqPH)) / 14);

    ctx.setLineDash([4, 4]);
    ctx.strokeStyle = 'rgba(155,93,229,0.45)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(eqX, py); ctx.lineTo(eqX, py + ph); ctx.stroke();
    ctx.setLineDash([]);

    /* equivalence diamond */
    ctx.save();
    ctx.shadowColor = TitrationSim.VIOLET; ctx.shadowBlur = 10;
    ctx.fillStyle = TitrationSim.VIOLET;
    ctx.beginPath();
    ctx.moveTo(eqX, eqY - 6); ctx.lineTo(eqX + 5, eqY);
    ctx.lineTo(eqX, eqY + 6); ctx.lineTo(eqX - 5, eqY);
    ctx.closePath(); ctx.fill();
    ctx.shadowBlur = 0; ctx.restore();

    ctx.fillStyle = 'rgba(155,93,229,0.70)';
    ctx.font = `bold 9px ${FNT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
    ctx.fillText('\u044D\u043A\u0432', eqX, eqY - 10);
    ctx.fillStyle = 'rgba(155,93,229,0.50)'; ctx.font = `8px ${FNT}`;
    ctx.fillText(`${eqV.toFixed(1)} mL`, eqX, eqY - 20);
  }
}

if (typeof module !== 'undefined') module.exports = TitrationSim;

/* ─── lab UI init ─────────────────────────────────── */
  function _openTitration() {
    document.getElementById('sim-topbar-title').textContent = 'pH и кривая титрования';
    _simShow('sim-titration');
    _registerSimState('titration', () => titrSim?.getParams(), st => titrSim?.setParams(st));
    if (_embedMode) _startStateEmit('titration');
    requestAnimationFrame(() => requestAnimationFrame(() => {
      if (!titrSim) {
        titrSim = new TitrationSim(document.getElementById('titration-canvas'));
        titrSim.onUpdate = _titrUpdateUI;
      }
      titrSim.fit();
      titrSim.reset();
      titrSim.play();
    }));
  }

  function titrParam(name, val) {
    const v = parseFloat(val);
    const ids = { acidConc: 'titr-ac-val', baseConc: 'titr-bc-val', acidVol: 'titr-vol-val' };
    const el = document.getElementById(ids[name]);
    if (el) el.textContent = name === 'acidVol' ? v : v.toFixed(2);
    if (titrSim) titrSim.setParams({ [name]: v });
  }

  function titrIndicator(name, btn) {
    document.querySelectorAll('.titr-ind-btn').forEach(b => b.classList.remove('active'));
    if (btn) btn.classList.add('active');
    if (titrSim) titrSim.setParams({ indicator: name });
  }

  function titrPreset(name) {
    if (titrSim) { titrSim.preset(name); titrSim.play(); }
    const defs = { strong_strong: [0.1,0.1,50], weak_strong: [0.1,0.1,50], concentrated: [0.5,0.5,25] };
    const d = defs[name] || defs.strong_strong;
    document.getElementById('sl-titr-ac').value = d[0]; document.getElementById('titr-ac-val').textContent = d[0].toFixed(2);
    document.getElementById('sl-titr-bc').value = d[1]; document.getElementById('titr-bc-val').textContent = d[1].toFixed(2);
    document.getElementById('sl-titr-vol').value = d[2]; document.getElementById('titr-vol-val').textContent = d[2];
  }

  function _titrUpdateUI(info) {
    const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
    v('titrbar-v1', info.pH);
    v('titrbar-v2', info.baseAdded + ' мл');
    v('titrbar-v3', info.eqPoint + ' мл');
    const indNames = { phenolphthalein: 'Фенолф.', methyl_orange: 'Метилор.', litmus: 'Лакмус' };
    v('titrbar-v4', indNames[info.indicator] || info.indicator);
  }

  /* ── refraction ── */

