'use strict';
/**
 * _chem_visuals.js — shared lab-glassware drawing helpers
 * Used by: chemsandbox, flask, titration, electrolysis, ionexchange, redox
 *
 * Public API:
 *   ChemVisuals.drawErlenmeyer(ctx, x, y, w, h, fillColor)
 *   ChemVisuals.drawBeaker(ctx, x, y, w, h, fillColor, opts)
 *   ChemVisuals.drawBurette(ctx, x, y, h, fillColor, valveOpen)
 *   ChemVisuals.drawTube(ctx, x, y, h, fillColor)
 *   ChemVisuals.drawSpiritLamp(ctx, x, y, flameOn, t)
 *   ChemVisuals.animateGasBubbles(ctx, x, y, color, t)
 *   ChemVisuals.animatePrecipitateFall(ctx, x, y, color, t)
 *   ChemVisuals.drawProductLabel(ctx, x, y, text, type, age)
 *   ChemVisuals.drawEduTooltip(ctx, x, y, w, lines, age)
 *   ChemVisuals.drawDeskBackground(ctx, W, H, tableY)
 *   ChemVisuals.drawPHStrip(ctx, x, y, pH)
 */

window.ChemVisuals = (() => {

  /* ── internal helpers ─────────────────────────────────────────── */

  function _rrect(ctx, x, y, w, h, r) {
    r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.lineTo(x + w - r, y);
    ctx.arcTo(x + w, y, x + w, y + r, r);
    ctx.lineTo(x + w, y + h - r);
    ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
    ctx.lineTo(x + r, y + h);
    ctx.arcTo(x, y + h, x, y + h - r, r);
    ctx.lineTo(x, y + r);
    ctx.arcTo(x, y, x + r, y, r);
    ctx.closePath();
  }

  function _glassHighlight(ctx, x, y, w, h) {
    /* left-edge specular stripe */
    const hl = ctx.createLinearGradient(x, y, x + w * 0.28, y);
    hl.addColorStop(0,    'rgba(255,255,255,0.22)');
    hl.addColorStop(0.45, 'rgba(255,255,255,0.07)');
    hl.addColorStop(1,    'rgba(255,255,255,0.00)');
    ctx.save();
    ctx.fillStyle = hl;
    ctx.fillRect(x + 2, y + 4, w * 0.26, h - 8);
    ctx.restore();
  }

  /* ── Erlenmeyer flask (Коническая колба) ─────────────────────── */
  /* x,y = top-center of neck; w = body width; h = total height    */
  function drawErlenmeyer(ctx, x, y, w, h, fillColor) {
    const nw  = w * 0.14;   /* half-neck-width */
    const nb  = y + h * 0.28; /* neck-bottom y */
    const bHalf = w * 0.48; /* half-body-width */
    const bot = y + h;

    /* erlenmeyer path: straight neck → shoulder curve → flat bottom */
    const erlPath = () => {
      ctx.beginPath();
      ctx.moveTo(x - nw, y);
      ctx.lineTo(x - nw, nb);
      /* left shoulder */
      ctx.bezierCurveTo(x - nw, nb + h * 0.18, x - bHalf, bot - h * 0.10, x - bHalf, bot);
      ctx.lineTo(x + bHalf, bot);
      /* right shoulder */
      ctx.bezierCurveTo(x + bHalf, bot - h * 0.10, x + nw, nb + h * 0.18, x + nw, nb);
      ctx.lineTo(x + nw, y);
      ctx.closePath();
    };

    /* liquid fill */
    if (fillColor) {
      ctx.save();
      erlPath();
      ctx.clip();
      const fillTop = nb + h * 0.22;
      const fg = ctx.createLinearGradient(0, fillTop, 0, bot);
      fg.addColorStop(0, _alpha(fillColor, 0.35));
      fg.addColorStop(1, _alpha(fillColor, 0.65));
      ctx.fillStyle = fg;
      ctx.fillRect(x - bHalf - 2, fillTop, bHalf * 2 + 4, bot - fillTop + 2);
      ctx.restore();
    }

    /* glass tint fill */
    ctx.save();
    erlPath();
    const gf = ctx.createLinearGradient(x - bHalf, y, x + bHalf, bot);
    gf.addColorStop(0, 'rgba(220,238,255,0.07)');
    gf.addColorStop(1, 'rgba(180,210,255,0.03)');
    ctx.fillStyle = gf;
    ctx.fill();

    /* outline */
    ctx.strokeStyle = 'rgba(140,190,255,0.70)';
    ctx.lineWidth = 1.8;
    ctx.stroke();
    ctx.restore();

    /* neck rim ellipse */
    ctx.save();
    ctx.beginPath();
    ctx.ellipse(x, y, nw + 2, 3, 0, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(180,215,255,0.55)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.restore();

    /* specular left stripe */
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(x - nw + 2, y + 6);
    ctx.lineTo(x - nw + 2, nb + 4);
    ctx.bezierCurveTo(x - nw + 2, nb + h * 0.22, x - bHalf * 0.68, bot - h * 0.18, x - bHalf * 0.72, bot - h * 0.10);
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 2.5;
    ctx.stroke();
    ctx.restore();

    /* graduation marks on body */
    ctx.save();
    ctx.strokeStyle = 'rgba(180,210,255,0.25)';
    ctx.lineWidth = 0.8;
    for (let i = 1; i <= 3; i++) {
      const gy = nb + (bot - nb) * (i / 4);
      const hw = nw + (bHalf - nw) * (i / 4) - 4;
      ctx.beginPath();
      ctx.moveTo(x - hw, gy);
      ctx.lineTo(x - hw + 8, gy);
      ctx.stroke();
    }
    ctx.restore();
  }

  /* ── Beaker (Химический стакан) ─────────────────────────────── */
  /* x,y = top-left corner; w,h = dimensions                       */
  function drawBeaker(ctx, x, y, w, h, fillColor, opts) {
    opts = opts || {};
    const hasPour  = opts.spout !== false; /* small spout notch top-right */
    const r        = 5;

    /* beaker outline path — open top, straight walls, rounded bottom */
    const bPath = () => {
      ctx.beginPath();
      ctx.moveTo(x, y);
      if (hasPour) {
        ctx.lineTo(x + w - 14, y);
        ctx.lineTo(x + w - 10, y - 7);
        ctx.lineTo(x + w + 2, y - 7);
        ctx.lineTo(x + w, y);
      } else {
        ctx.lineTo(x + w, y);
      }
      ctx.lineTo(x + w, y + h - r);
      ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
      ctx.lineTo(x + r, y + h);
      ctx.arcTo(x, y + h, x, y + h - r, r);
      ctx.lineTo(x, y);
    };

    /* liquid fill clipped */
    if (fillColor) {
      ctx.save();
      bPath();
      ctx.clip();
      const liqTop = y + h * 0.25;
      const lg = ctx.createLinearGradient(0, liqTop, 0, y + h);
      lg.addColorStop(0, _alpha(fillColor, 0.30));
      lg.addColorStop(1, _alpha(fillColor, 0.60));
      ctx.fillStyle = lg;
      ctx.fillRect(x + 1, liqTop, w - 2, h);
      ctx.restore();
    }

    /* glass tint */
    ctx.save();
    bPath();
    const gf = ctx.createLinearGradient(x, y, x + w, y + h);
    gf.addColorStop(0, 'rgba(220,238,255,0.06)');
    gf.addColorStop(1, 'rgba(180,210,255,0.02)');
    ctx.fillStyle = gf;
    ctx.fill();
    ctx.strokeStyle = 'rgba(130,185,255,0.60)';
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.restore();

    /* graduation scale on right wall */
    ctx.save();
    ctx.strokeStyle = 'rgba(170,205,255,0.28)';
    ctx.lineWidth = 0.8;
    ctx.font = '7px monospace';
    ctx.fillStyle = 'rgba(170,205,255,0.40)';
    ctx.textAlign = 'right';
    for (let i = 1; i <= 4; i++) {
      const gy = y + h * 0.85 - (h * 0.55 * i / 4);
      ctx.beginPath();
      ctx.moveTo(x + w, gy);
      ctx.lineTo(x + w - (i % 2 === 0 ? 8 : 4), gy);
      ctx.stroke();
      if (i % 2 === 0) ctx.fillText((i * 25).toString(), x + w - 10, gy + 3);
    }
    ctx.restore();

    /* specular highlight */
    _glassHighlight(ctx, x, y, w, h);
  }

  /* ── Burette (Бюретка) ──────────────────────────────────────── */
  /* x,y = top-center; h = length; frac = fill fraction 0..1      */
  function drawBurette(ctx, x, y, h, fillColor, valveOpen) {
    const bw = 10; /* half-width */
    const valveY = y + h - 12;

    /* glass tube */
    ctx.save();
    const gg = ctx.createLinearGradient(x - bw, 0, x + bw, 0);
    gg.addColorStop(0,    'rgba(140,185,255,0.22)');
    gg.addColorStop(0.38, 'rgba(170,210,255,0.08)');
    gg.addColorStop(0.62, 'rgba(170,210,255,0.08)');
    gg.addColorStop(1,    'rgba(120,165,245,0.18)');
    ctx.fillStyle = gg;
    _rrect(ctx, x - bw, y, bw * 2, h - 10, 4);
    ctx.fill();
    ctx.strokeStyle = 'rgba(130,185,255,0.55)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.restore();

    /* liquid inside */
    if (fillColor) {
      const lh = h * 0.85;
      ctx.save();
      _rrect(ctx, x - bw + 2, y + 4, bw * 2 - 4, lh - 4, 3);
      ctx.clip();
      const lg = ctx.createLinearGradient(0, y, 0, y + lh);
      lg.addColorStop(0, _alpha(fillColor, 0.25));
      lg.addColorStop(1, _alpha(fillColor, 0.45));
      ctx.fillStyle = lg;
      ctx.fillRect(x - bw + 2, y + 4, bw * 2 - 4, lh - 4);
      ctx.restore();
    }

    /* graduation lines */
    ctx.save();
    ctx.strokeStyle = 'rgba(170,210,255,0.32)';
    ctx.lineWidth = 0.7;
    ctx.font = '7px monospace';
    ctx.fillStyle = 'rgba(170,210,255,0.45)';
    ctx.textAlign = 'left';
    for (let i = 0; i <= 10; i++) {
      const gy = y + 6 + (h - 18) * (i / 10);
      const maj = i % 2 === 0;
      ctx.beginPath();
      ctx.moveTo(x + bw, gy);
      ctx.lineTo(x + bw + (maj ? 7 : 3), gy);
      ctx.stroke();
      if (maj) ctx.fillText((i * 5).toString(), x + bw + 9, gy + 3);
    }
    ctx.restore();

    /* stopcock */
    ctx.save();
    ctx.fillStyle = valveOpen ? 'rgba(100,180,120,0.70)' : 'rgba(190,170,140,0.65)';
    _rrect(ctx, x - bw - 3, valveY, bw * 2 + 6, 8, 3);
    ctx.fill();
    ctx.strokeStyle = 'rgba(200,200,200,0.35)';
    ctx.lineWidth = 1;
    ctx.stroke();
    ctx.restore();

    /* nozzle */
    ctx.save();
    ctx.fillStyle = 'rgba(150,175,220,0.55)';
    ctx.beginPath();
    ctx.moveTo(x - 3, valveY + 8);
    ctx.lineTo(x + 3, valveY + 8);
    ctx.lineTo(x + 1.5, valveY + 16);
    ctx.lineTo(x - 1.5, valveY + 16);
    ctx.closePath();
    ctx.fill();
    ctx.restore();

    /* specular */
    ctx.save();
    ctx.strokeStyle = 'rgba(220,240,255,0.25)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(x - bw + 3, y + 8);
    ctx.lineTo(x - bw + 3, valveY - 6);
    ctx.stroke();
    ctx.restore();
  }

  /* ── Test tube (Пробирка) ────────────────────────────────────── */
  /* x,y = top-center; h = length; w = half-width                  */
  function drawTube(ctx, x, y, h, fillColor) {
    const w = 9;
    const tubeBot = y + h;

    /* tube path: open top, rounded bottom */
    const tubePath = () => {
      ctx.beginPath();
      ctx.moveTo(x - w, y);
      ctx.lineTo(x - w, tubeBot - w);
      ctx.arc(x, tubeBot - w, w, Math.PI, 0, true);
      ctx.lineTo(x + w, y);
    };

    /* fill */
    if (fillColor) {
      ctx.save();
      tubePath();
      ctx.clip();
      const liqTop = y + h * 0.35;
      const lg = ctx.createLinearGradient(0, liqTop, 0, tubeBot);
      lg.addColorStop(0, _alpha(fillColor, 0.35));
      lg.addColorStop(1, _alpha(fillColor, 0.65));
      ctx.fillStyle = lg;
      ctx.fillRect(x - w, liqTop, w * 2, tubeBot - liqTop + 2);
      ctx.restore();
    }

    /* glass */
    ctx.save();
    tubePath();
    const gf = ctx.createLinearGradient(x - w, y, x + w, tubeBot);
    gf.addColorStop(0, 'rgba(210,235,255,0.08)');
    gf.addColorStop(1, 'rgba(180,215,255,0.02)');
    ctx.fillStyle = gf;
    ctx.fill();
    ctx.strokeStyle = 'rgba(140,190,255,0.65)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.restore();

    /* highlight */
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(x - w + 2.5, y + 6);
    ctx.lineTo(x - w + 2.5, tubeBot - w * 1.4);
    ctx.stroke();
    ctx.restore();
  }

  /* ── Spirit lamp (Спиртовка) ─────────────────────────────────── */
  /* x,y = bottom-center of lamp base; flameOn = bool; t = anim time */
  function drawSpiritLamp(ctx, x, y, flameOn, t) {
    t = t || 0;

    /* base / reservoir */
    const rw = 22, rh = 18;
    ctx.save();
    const bg = ctx.createLinearGradient(x - rw, y - rh, x + rw, y);
    bg.addColorStop(0, 'rgba(180,210,255,0.25)');
    bg.addColorStop(1, 'rgba(140,170,220,0.15)');
    ctx.fillStyle = bg;
    _rrect(ctx, x - rw, y - rh, rw * 2, rh, 5);
    ctx.fill();
    ctx.strokeStyle = 'rgba(140,185,255,0.55)';
    ctx.lineWidth = 1.5;
    ctx.stroke();

    /* alcohol inside (tinted) */
    ctx.save();
    _rrect(ctx, x - rw + 2, y - rh + 3, rw * 2 - 4, rh - 5, 4);
    ctx.clip();
    ctx.fillStyle = 'rgba(160,200,255,0.20)';
    ctx.fillRect(x - rw + 2, y - rh * 0.5, rw * 2 - 4, rh);
    ctx.restore();

    /* specular on reservoir */
    ctx.strokeStyle = 'rgba(220,240,255,0.25)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(x - rw + 4, y - rh + 5);
    ctx.lineTo(x - rw + 4, y - 5);
    ctx.stroke();
    ctx.restore();

    /* wick holder + wick */
    ctx.save();
    ctx.fillStyle = 'rgba(180,175,165,0.70)';
    _rrect(ctx, x - 4, y - rh - 8, 8, 10, 2);
    ctx.fill();
    /* wick */
    ctx.fillStyle = 'rgba(240,235,215,0.85)';
    ctx.fillRect(x - 1.5, y - rh - 6, 3, 7);
    ctx.restore();

    /* flame */
    if (flameOn) {
      const flickX = Math.sin(t * 7.3) * 2.0 + Math.sin(t * 13.1) * 0.8;
      const flickY = Math.abs(Math.sin(t * 5.7)) * 2.0;
      const fTop   = y - rh - 22 - flickY;
      const fMid   = y - rh - 14;
      const fBase  = y - rh - 8;

      /* outer flame (orange) */
      ctx.save();
      ctx.beginPath();
      ctx.moveTo(x + flickX - 6, fBase);
      ctx.bezierCurveTo(x + flickX - 8, fMid, x + flickX - 3, fTop + 6, x + flickX, fTop);
      ctx.bezierCurveTo(x + flickX + 3, fTop + 6, x + flickX + 8, fMid, x + flickX + 6, fBase);
      ctx.closePath();
      const fg = ctx.createLinearGradient(x, fBase, x, fTop);
      fg.addColorStop(0, 'rgba(255,120,10,0.90)');
      fg.addColorStop(0.5, 'rgba(255,185,40,0.75)');
      fg.addColorStop(1, 'rgba(255,240,140,0.35)');
      ctx.fillStyle = fg;
      ctx.shadowColor = 'rgba(255,140,20,0.55)';
      ctx.shadowBlur  = 14;
      ctx.fill();
      ctx.restore();

      /* inner blue core */
      ctx.save();
      ctx.beginPath();
      const cTop = fMid + (fBase - fMid) * 0.15;
      ctx.moveTo(x - 2.5, fBase);
      ctx.bezierCurveTo(x - 3, fMid + 4, x - 1.5, cTop + 3, x, cTop);
      ctx.bezierCurveTo(x + 1.5, cTop + 3, x + 3, fMid + 4, x + 2.5, fBase);
      ctx.closePath();
      const cf = ctx.createLinearGradient(x, fBase, x, cTop);
      cf.addColorStop(0, 'rgba(80,160,255,0.80)');
      cf.addColorStop(1, 'rgba(120,200,255,0.20)');
      ctx.fillStyle = cf;
      ctx.fill();
      ctx.restore();

      /* warm glow on table beneath lamp */
      ctx.save();
      const gl = ctx.createRadialGradient(x, y, 0, x, y, rw * 1.8);
      gl.addColorStop(0, 'rgba(255,130,20,0.12)');
      gl.addColorStop(1, 'rgba(255,130,20,0.00)');
      ctx.fillStyle = gl;
      ctx.scale(1, 0.25);
      ctx.beginPath();
      ctx.arc(x, y / 0.25, rw * 1.8, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();
    }
  }

  /* ── Gas bubbles animation ───────────────────────────────────── */
  /* Draws 3 ascending bubbles at staggered offsets                 */
  function animateGasBubbles(ctx, x, y, color, t) {
    t = t || 0;
    for (let i = 0; i < 3; i++) {
      const phase = (t * 1.4 + i * 0.8) % 2.5;
      if (phase > 2.0) continue; /* fade in/out cycle */
      const bx  = x + (i - 1) * 6 + Math.sin(phase * 3.1 + i) * 3;
      const by  = y - phase * 18;
      const br  = 2.8 + Math.sin(phase * 2) * 0.6;
      const alp = Math.min(1, phase < 1.6 ? phase / 0.4 : (2.0 - phase) / 0.4);
      ctx.save();
      ctx.globalAlpha = alp * 0.60;
      ctx.beginPath();
      ctx.arc(bx, by, br, 0, Math.PI * 2);
      ctx.strokeStyle = color || 'rgba(200,230,255,0.9)';
      ctx.lineWidth = 0.9;
      ctx.stroke();
      /* glint */
      ctx.beginPath();
      ctx.arc(bx - br * 0.3, by - br * 0.3, br * 0.22, 0, Math.PI * 2);
      ctx.fillStyle = 'rgba(255,255,255,0.7)';
      ctx.fill();
      ctx.restore();
    }
  }

  /* ── Animated up-arrow for gas product ─────────────────────── */
  function _drawArrowUp(ctx, x, y, color, alpha) {
    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.strokeStyle = color;
    ctx.fillStyle   = color;
    ctx.lineWidth   = 1.8;
    /* shaft */
    ctx.beginPath();
    ctx.moveTo(x, y + 10);
    ctx.lineTo(x, y + 2);
    ctx.stroke();
    /* head */
    ctx.beginPath();
    ctx.moveTo(x - 5, y + 6);
    ctx.lineTo(x, y);
    ctx.lineTo(x + 5, y + 6);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  /* ── Animated down-arrow for precipitate ────────────────────── */
  function _drawArrowDown(ctx, x, y, color, alpha) {
    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.strokeStyle = color;
    ctx.fillStyle   = color;
    ctx.lineWidth   = 1.8;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x, y + 8);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x - 5, y + 4);
    ctx.lineTo(x, y + 10);
    ctx.lineTo(x + 5, y + 4);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  /* ── Precipitate falling particles ──────────────────────────── */
  function animatePrecipitateFall(ctx, x, y, color, t) {
    t = t || 0;
    for (let i = 0; i < 4; i++) {
      const phase = (t * 0.9 + i * 0.7) % 2.8;
      if (phase > 2.2) continue;
      const px  = x + (i - 1.5) * 7 + Math.sin(phase * 2.5 + i * 1.1) * 3;
      const py  = y + phase * 14;
      const pr  = 2.2 + Math.sin(i) * 0.6;
      const alp = Math.min(1, phase < 1.8 ? 1 : (2.2 - phase) / 0.4);
      ctx.save();
      ctx.globalAlpha = alp * 0.65;
      ctx.beginPath();
      ctx.arc(px, py, pr, 0, Math.PI * 2);
      ctx.fillStyle = color || '#CCCCCC';
      ctx.fill();
      ctx.restore();
    }
  }

  /* ── Product label (gas or precipitate) ────────────────────── */
  /* age: 0..1, 0=appear, 1=disappear                              */
  function drawProductLabel(ctx, x, y, text, type, age) {
    /* type: 'gas' | 'precip' | 'liquid' */
    age = Math.max(0, Math.min(1, age === undefined ? 0 : age));
    const alpha = age < 0.15 ? age / 0.15 : age > 0.75 ? (1 - age) / 0.25 : 1.0;
    if (alpha <= 0) return;

    const isGas    = type === 'gas';
    const isPrec   = type === 'precip';
    const color    = isGas  ? '#A8E6FF' :
                     isPrec ? '#FFD580' : '#9FF0B0';
    const arrowY   = isGas ? y - 18 : y + 8;
    const textY    = isGas ? y - 34 : y + 28;
    const offsetX  = isGas ? 0 : 0;

    /* bouncing offset for gas (rises) */
    const drift = isGas ? -age * 8 : age * 6;

    if (isGas) {
      _drawArrowUp(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9);
    } else if (isPrec) {
      _drawArrowDown(ctx, x + offsetX, arrowY + drift, color, alpha * 0.9);
    }

    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.font = 'bold 10px "Courier New", monospace';
    ctx.fillStyle = color;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = color;
    ctx.shadowBlur  = 8;
    ctx.fillText(text, x + offsetX, textY + drift);
    ctx.shadowBlur = 0;
    ctx.restore();
  }

  /* ── Educational tooltip bubble ─────────────────────────────── */
  /* x,y = pointer tip; lines = string[]; age = 0..1              */
  function drawEduTooltip(ctx, x, y, w, lines, age) {
    age = Math.max(0, Math.min(1, age === undefined ? 0 : age));
    const alpha = age < 0.12 ? age / 0.12 : age > 0.75 ? (1 - age) / 0.25 : 1.0;
    if (alpha <= 0.02) return;

    const lineH  = 14;
    const pad    = 10;
    const bh     = lines.length * lineH + pad * 2;
    const bx     = x - w / 2;
    const by     = y - bh - 14;

    ctx.save();
    ctx.globalAlpha = alpha;

    /* shadow */
    ctx.shadowColor = 'rgba(0,80,180,0.35)';
    ctx.shadowBlur  = 12;

    /* background */
    _rrect(ctx, bx, by, w, bh, 7);
    const bg = ctx.createLinearGradient(bx, by, bx, by + bh);
    bg.addColorStop(0, 'rgba(10,25,60,0.94)');
    bg.addColorStop(1, 'rgba(6,14,40,0.96)');
    ctx.fillStyle = bg;
    ctx.fill();
    ctx.shadowBlur = 0;

    /* border */
    ctx.strokeStyle = 'rgba(80,145,255,0.50)';
    ctx.lineWidth = 1.2;
    ctx.stroke();

    /* pointer triangle */
    const ptx = x;
    ctx.beginPath();
    ctx.moveTo(ptx - 7, by + bh);
    ctx.lineTo(ptx, by + bh + 11);
    ctx.lineTo(ptx + 7, by + bh);
    ctx.closePath();
    ctx.fillStyle = 'rgba(10,25,60,0.94)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(80,145,255,0.50)';
    ctx.lineWidth = 1.2;
    ctx.stroke();

    /* left accent bar */
    ctx.fillStyle = 'rgba(80,145,255,0.60)';
    ctx.fillRect(bx + 5, by + pad, 2.5, bh - pad * 2);

    /* text lines */
    ctx.font = '10.5px "Manrope", sans-serif';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    lines.forEach((line, i) => {
      ctx.fillStyle = i === 0 ? 'rgba(140,195,255,0.95)' : 'rgba(200,220,255,0.80)';
      ctx.fillText(line, bx + 15, by + pad + lineH * i + lineH / 2);
    });

    ctx.restore();
  }

  /* ── Laboratory desk background ─────────────────────────────── */
  /* Draws warm desk surface + subtle shadow under vessel           */
  function drawDeskBackground(ctx, W, H, tableY) {
    tableY = tableY || H * 0.78;

    /* wooden desk stripe */
    ctx.save();
    const dg = ctx.createLinearGradient(0, tableY, 0, H);
    dg.addColorStop(0, '#1e1610');
    dg.addColorStop(0.15, '#261c12');
    dg.addColorStop(1, '#0e0b08');
    ctx.fillStyle = dg;
    ctx.fillRect(0, tableY, W, H - tableY);

    /* wood grain lines */
    ctx.strokeStyle = 'rgba(255,200,120,0.04)';
    ctx.lineWidth = 1;
    for (let i = 0; i < 5; i++) {
      const gy = tableY + 8 + i * 12;
      ctx.beginPath();
      ctx.moveTo(0, gy);
      ctx.lineTo(W, gy + (Math.random() * 4 - 2));
      ctx.stroke();
    }

    /* edge highlight */
    ctx.strokeStyle = 'rgba(200,160,100,0.18)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(0, tableY);
    ctx.lineTo(W, tableY);
    ctx.stroke();
    ctx.restore();
  }

  /* ── Vessel shadow on desk ───────────────────────────────────── */
  function drawVesselShadow(ctx, cx, tableY, radius) {
    ctx.save();
    ctx.scale(1, 0.22);
    const sg = ctx.createRadialGradient(cx, tableY / 0.22, 0, cx, tableY / 0.22, radius * 1.3);
    sg.addColorStop(0, 'rgba(0,0,0,0.35)');
    sg.addColorStop(0.5, 'rgba(0,0,0,0.14)');
    sg.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = sg;
    ctx.beginPath();
    ctx.arc(cx, tableY / 0.22, radius * 1.3, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  /* ── pH strip indicator ─────────────────────────────────────── */
  /* x,y = left edge top; pH = current value                       */
  function drawPHStrip(ctx, x, y, pH) {
    const sw = 14, sh = 90;
    const strip = [
      { ph: 0,  rgb: [180, 0,   0] },
      { ph: 3,  rgb: [220, 40,  40] },
      { ph: 4,  rgb: [255, 130, 50] },
      { ph: 5,  rgb: [255, 215, 60] },
      { ph: 6,  rgb: [170, 220, 80] },
      { ph: 7,  rgb: [60,  185, 100] },
      { ph: 8,  rgb: [40,  155, 180] },
      { ph: 9,  rgb: [50,  100, 210] },
      { ph: 11, rgb: [100, 60,  180] },
      { ph: 14, rgb: [80,  20,  140] },
    ];

    /* rainbow gradient */
    const gr = ctx.createLinearGradient(0, y, 0, y + sh);
    strip.forEach(s => {
      gr.addColorStop(s.ph / 14, `rgb(${s.rgb[0]},${s.rgb[1]},${s.rgb[2]})`);
    });

    ctx.save();
    _rrect(ctx, x, y, sw, sh, 3);
    ctx.fillStyle = gr;
    ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.20)';
    ctx.lineWidth = 1;
    ctx.stroke();

    /* pH value labels */
    ctx.font = '6.5px monospace';
    ctx.fillStyle = 'rgba(255,255,255,0.40)';
    ctx.textAlign = 'left';
    [0, 4, 7, 10, 14].forEach(v => {
      const gy = y + (v / 14) * sh;
      ctx.fillText(v.toString(), x + sw + 3, gy + 2);
    });

    /* marker for current pH */
    const markerY = y + (Math.min(14, Math.max(0, pH)) / 14) * sh;
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.85)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(x - 4, markerY);
    ctx.lineTo(x + sw + 2, markerY);
    ctx.stroke();
    /* marker arrowhead */
    ctx.fillStyle = 'rgba(255,255,255,0.85)';
    ctx.beginPath();
    ctx.moveTo(x - 4, markerY - 4);
    ctx.lineTo(x - 4, markerY + 4);
    ctx.lineTo(x - 10, markerY);
    ctx.closePath();
    ctx.fill();
    ctx.restore();

    ctx.restore();
  }

  /* ── Color utility ──────────────────────────────────────────── */
  function _alpha(hex, a) {
    if (hex.startsWith('#')) {
      const n = parseInt(hex.slice(1), 16);
      return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
    }
    /* already rgb/rgba — just return with rough alpha inject */
    if (hex.startsWith('rgba')) return hex.replace(/[\d.]+\)$/, a + ')');
    if (hex.startsWith('rgb(')) return hex.replace('rgb(', 'rgba(').replace(')', `,${a})`);
    return hex;
  }

  /* ── Public API ─────────────────────────────────────────────── */
  return {
    drawErlenmeyer,
    drawBeaker,
    drawBurette,
    drawTube,
    drawSpiritLamp,
    animateGasBubbles,
    animatePrecipitateFall,
    drawProductLabel,
    drawEduTooltip,
    drawDeskBackground,
    drawVesselShadow,
    drawPHStrip,
  };
})();
