'use strict';

/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
  if (!s || !s.includes('<svg')) return s;
  return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
    if (m.includes('x1="5" y1="12" x2="19"'))  return '\u2192'; // → right arrow
    if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
    if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
    return '';
  });
}

/* ════════════════════════════════════════════════════════════════
   ChemSandboxSim v2 — «Химическая песочница»
   • Колба Эрленмейера с реалистичным стеклом
   • Анимация вливания реагента сверху
   • Удаление отдельного реагента из зоны
   • Drag реагентов с полки
   • Визуальные эффекты: осадки, газы, смена цвета, нагрев
   ════════════════════════════════════════════════════════════════ */

class ChemSandboxSim {

  /* ── База веществ ─────────────────────────────────────────────── */

  static SUBSTANCES = {
    'HCl':     { name: 'Соляная к-та',       state: 'aq', color: '#78D278', cat: 'acid' },
    'H2SO4':   { name: 'Серная к-та',        state: 'aq', color: '#D2C378', cat: 'acid' },
    'HNO3':    { name: 'Азотная к-та',       state: 'aq', color: '#E8D060', cat: 'acid' },
    'CH3COOH': { name: 'Уксусная к-та',     state: 'aq', color: '#C8E0A0', cat: 'acid' },
    'NaOH':    { name: 'Гидроксид натрия',   state: 'aq', color: '#7BF5A4', cat: 'base' },
    'KOH':     { name: 'Гидроксид калия',    state: 'aq', color: '#7BF5A4', cat: 'base' },
    'Ca(OH)2': { name: 'Гидроксид кальция',  state: 'aq', color: '#E0E0E0', cat: 'base' },
    'NH3·H2O': { name: 'Аммиачная вода',     state: 'aq', color: '#A0D8F0', cat: 'base' },
    'NaCl':    { name: 'Хлорид натрия',      state: 's',  color: '#FFFFFF', cat: 'salt' },
    'CuSO4':   { name: 'Сульфат меди',       state: 'aq', color: '#4CC9F0', cat: 'salt' },
    'BaCl2':   { name: 'Хлорид бария',       state: 'aq', color: '#E0E0E0', cat: 'salt' },
    'AgNO3':   { name: 'Нитрат серебра',     state: 'aq', color: '#E0E0E0', cat: 'salt' },
    'Na2CO3':  { name: 'Карбонат натрия',    state: 'aq', color: '#E0E0E0', cat: 'salt' },
    'FeCl3':   { name: 'Хлорид железа(III)', state: 'aq', color: '#D4A040', cat: 'salt' },
    'Pb(NO3)2':{ name: 'Нитрат свинца',      state: 'aq', color: '#E0E0E0', cat: 'salt' },
    'K2CrO4':  { name: 'Хромат калия',       state: 'aq', color: '#FFD700', cat: 'salt' },
    'Zn':      { name: 'Цинк',              state: 's',  color: '#9BB8CC', cat: 'metal' },
    'Fe':      { name: 'Железо',             state: 's',  color: '#A08060', cat: 'metal' },
    'Cu':      { name: 'Медь',              state: 's',  color: '#C87840', cat: 'metal' },
    'Mg':      { name: 'Магний',             state: 's',  color: '#D6D6D6', cat: 'metal' },
    'Na':      { name: 'Натрий',             state: 's',  color: '#F5F0C8', cat: 'metal' },
    'H2O':     { name: 'Вода',              state: 'l',  color: '#6EB4D7', cat: 'other' },
    'Phenolphthalein': { name: 'Фенолфталеин', state: 'ind', color: '#E0E0E0', cat: 'indicator' },
    'Litmus':          { name: 'Лакмус',       state: 'ind', color: '#9B59B6', cat: 'indicator' },
    'MethylOrange':    { name: 'Метилоранж',   state: 'ind', color: '#FF8C00', cat: 'indicator' },
  };

  /* ── База реакций ─────────────────────────────────────────────── */

  static REACTIONS = [
    // ── Нейтрализация ──
    { r: ['HCl','NaOH'],       eq: 'HCl + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaCl + H₂O',               type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'H⁺ + Cl⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['H2SO4','NaOH'],     eq: 'H₂SO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + 2H₂O',        type: 'Нейтрализация', fx: { heat: true },
      ionFull: '2H⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['HNO3','NaOH'],      eq: 'HNO₃ + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaNO₃ + H₂O',             type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'H⁺ + NO₃⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['HCl','KOH'],        eq: 'HCl + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KCl + H₂O',                 type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'H⁺ + Cl⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['HCl','Ca(OH)2'],    eq: '2HCl + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCl₂ + 2H₂O',        type: 'Нейтрализация', fx: { heat: true },
      ionFull: '2H⁺ + 2Cl⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Ca²⁺ + 2Cl⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['CH3COOH','NaOH'],   eq: 'CH₃COOH + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COONa + H₂O',     type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'CH₃COOH + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + Na⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
      why: 'Слабая кислота реагирует с OH⁻ целиком (не диссоциирует полностью)' },
    { r: ['H2SO4','KOH'],      eq: 'H₂SO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K₂SO₄ + 2H₂O',         type: 'Нейтрализация', fx: { heat: true },
      ionFull: '2H⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2K⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['HNO3','KOH'],       eq: 'HNO₃ + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KNO₃ + H₂O',              type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'H⁺ + NO₃⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
      why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
    { r: ['CH3COOH','KOH'],    eq: 'CH₃COOH + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COOK + H₂O',        type: 'Нейтрализация', fx: { heat: true },
      ionFull: 'CH₃COOH + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + K⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
      why: 'Слабая кислота реагирует с OH⁻ целиком' },
    { r: ['H2SO4','Ca(OH)2'],  eq: 'H₂SO₄ + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O',     type: 'Нейтрализация', fx: { heat: true, precip: { c: '#F0F0F0', n: 'CaSO₄' } },
      ionFull: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O', ionNet: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O',
      why: 'Нейтрализация + образование малорастворимого CaSO₄' },
    // ── Замещение (металл + кислота) ──
    { r: ['Zn','HCl'],         eq: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',              type: 'Замещение',    fx: { gas: 'H₂', heat: true },
      ionFull: 'Zn⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
    { r: ['Zn','H2SO4'],       eq: 'Zn + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',            type: 'Замещение',    fx: { gas: 'H₂', heat: true },
      ionFull: 'Zn⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
    { r: ['Fe','HCl'],         eq: 'Fe + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',              type: 'Замещение',    fx: { gas: 'H₂', heat: true },
      ionFull: 'Fe⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
    { r: ['Fe','H2SO4'],       eq: 'Fe + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',            type: 'Замещение',    fx: { gas: 'H₂', heat: true },
      ionFull: 'Fe⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
    { r: ['Mg','HCl'],         eq: 'Mg + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',             type: 'Замещение',    fx: { gas: 'H₂', heat: true, violent: true },
      ionFull: 'Mg⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
    { r: ['Mg','H2SO4'],       eq: 'Mg + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',           type: 'Замещение',    fx: { gas: 'H₂', heat: true, violent: true },
      ionFull: 'Mg⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
    // ── Замещение (металл + соль) ──
    { r: ['CuSO4','Fe'],       eq: 'CuSO₄ + Fe <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',            type: 'Замещение',    fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#90C090' },
      ionFull: 'Cu²⁺ + SO₄²⁻ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Fe активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
    { r: ['CuSO4','Zn'],       eq: 'CuSO₄ + Zn <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',           type: 'Замещение',    fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#E0E0E0' },
      ionFull: 'Cu²⁺ + SO₄²⁻ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Zn активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
    { r: ['AgNO3','Cu'],       eq: '2AgNO₃ + Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(NO₃)₂ + 2Ag<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',       type: 'Замещение',    fx: { precip: { c: '#C0C0C0', n: 'Ag' }, colorTo: '#4CC9F0' },
      ionFull: '2Ag⁺ + 2NO₃⁻ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2NO₃⁻ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: '2Ag⁺ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Cu активнее Ag <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет серебро из раствора' },
    // ── Обмен с осадком ──
    { r: ['AgNO3','NaCl'],     eq: 'AgNO₃ + NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃',        type: 'Обмен',        fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
      ionFull: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
    { r: ['AgNO3','HCl'],      eq: 'AgNO₃ + HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + HNO₃',          type: 'Обмен',        fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
      ionFull: 'Ag⁺ + NO₃⁻ + H⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
    { r: ['BaCl2','H2SO4'],    eq: 'BaCl₂ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2HCl',      type: 'Обмен',        fx: { precip: { c: '#FFFFFF', n: 'BaSO₄' } },
      ionFull: 'Ba²⁺ + 2Cl⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H⁺ + 2Cl⁻', ionNet: 'Ba²⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'BaSO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
    { r: ['CuSO4','NaOH'],     eq: 'CuSO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na₂SO₄',  type: 'Обмен',        fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
      ionFull: 'Cu²⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> осаждается голубой гидроксид' },
    { r: ['FeCl3','NaOH'],     eq: 'FeCl₃ + 3NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3NaCl',    type: 'Обмен',        fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
      ionFull: 'Fe³⁺ + 3Cl⁻ + 3Na⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3Na⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
    { r: ['Pb(NO3)2','K2CrO4'],eq: 'Pb(NO₃)₂ + K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',type:'Обмен',        fx: { precip: { c: '#FFD700', n: 'PbCrO₄' } },
      ionFull: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'PbCrO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> яркий жёлтый осадок' },
    { r: ['FeCl3','K2CrO4'],   eq: '2FeCl₃ + 3K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6KCl',type:'Обмен',      fx: { precip: { c: '#8B6914', n: 'Fe₂(CrO₄)₃' } },
      ionFull: '2Fe³⁺ + 6Cl⁻ + 6K⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6K⁺ + 6Cl⁻', ionNet: '2Fe³⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Fe₂(CrO₄)₃ нерастворим' },
    { r: ['Pb(NO3)2','NaCl'],  eq: 'Pb(NO₃)₂ + 2NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaNO₃', type: 'Обмен',        fx: { precip: { c: '#F0F0F0', n: 'PbCl₂' } },
      ionFull: 'Pb²⁺ + 2NO₃⁻ + 2Na⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'PbCl₂ малорастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> белый осадок' },
    { r: ['CuSO4','KOH'],      eq: 'CuSO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + K₂SO₄',    type: 'Обмен',        fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
      ionFull: 'Cu²⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> голубой осадок' },
    { r: ['FeCl3','KOH'],      eq: 'FeCl₃ + 3KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3KCl',      type: 'Обмен',        fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
      ionFull: 'Fe³⁺ + 3Cl⁻ + 3K⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3K⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
      why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
    // ── Обмен с газом ──
    { r: ['Na2CO3','HCl'],     eq: 'Na₂CO₃ + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',  type: 'Обмен',        fx: { gas: 'CO₂' },
      ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2Cl⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
    { r: ['Na2CO3','H2SO4'],   eq: 'Na₂CO₃ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен',        fx: { gas: 'CO₂' },
      ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
    { r: ['Na2CO3','HNO3'],    eq: 'Na₂CO₃ + 2HNO₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaNO₃ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен',        fx: { gas: 'CO₂' },
      ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2NO₃⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2NO₃⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
    { r: ['Na2CO3','CH3COOH'], eq: 'Na₂CO₃ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COONa + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен',  fx: { gas: 'CO₂' },
      ionFull: '2Na⁺ + CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + 2Na⁺ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Уксусная кислота слабая, но карбонат-ион связывает H⁺' },
    // ── Активный металл + вода ──
    { r: ['Na','H2O'],         eq: '2Na + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaOH + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',            type: 'Акт. металл',  fx: { gas: 'H₂', heat: true, violent: true },
      ionNet: '2Na⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2OH⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Na — щелочной металл, бурно реагирует с водой' },
    { r: ['Mg','H2O'],         eq: 'Mg + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',          type: 'Акт. металл',  fx: { gas: 'H₂', heat: true, precip: { c: '#E0E0E0', n: 'Mg(OH)₂' } },
      ionNet: 'Mg⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
      why: 'Mg реагирует с горячей водой, Mg(OH)₂ малорастворим' },
    // ── Индикаторы ──
    { r: ['Phenolphthalein','NaOH'],  eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый',      type: 'Индикатор', fx: { colorTo: '#FF1493' },
      why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
    { r: ['Phenolphthalein','KOH'],   eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый',      type: 'Индикатор', fx: { colorTo: '#FF1493' },
      why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
    { r: ['Phenolphthalein','Ca(OH)2'],eq:'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый',      type: 'Индикатор', fx: { colorTo: '#FF1493' },
      why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
    { r: ['Phenolphthalein','NH3·H2O'],eq:'Фенолфталеин + аммиак <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бл.-розовый',    type: 'Индикатор', fx: { colorTo: '#FFB0C0' },
      why: 'NH₃·H₂O — слабое основание, pH ~11, бледная окраска' },
    { r: ['Litmus','HCl'],            eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный',             type: 'Индикатор', fx: { colorTo: '#EF476F' },
      why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
    { r: ['Litmus','H2SO4'],          eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный',             type: 'Индикатор', fx: { colorTo: '#EF476F' },
      why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
    { r: ['Litmus','HNO3'],           eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный',             type: 'Индикатор', fx: { colorTo: '#EF476F' },
      why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
    { r: ['Litmus','NaOH'],           eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий',                type: 'Индикатор', fx: { colorTo: '#4466FF' },
      why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
    { r: ['Litmus','KOH'],            eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий',                type: 'Индикатор', fx: { colorTo: '#4466FF' },
      why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
    { r: ['MethylOrange','HCl'],      eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый',         type: 'Индикатор', fx: { colorTo: '#FF6666' },
      why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
    { r: ['MethylOrange','H2SO4'],    eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый',         type: 'Индикатор', fx: { colorTo: '#FF6666' },
      why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
    { r: ['MethylOrange','NaOH'],     eq: 'Метилоранж + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> жёлтый',           type: 'Индикатор', fx: { colorTo: '#FFD700' },
      why: 'pH > 4.4 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж жёлтый' },
    // ── Нет реакции ──
    { r: ['Cu','HCl'],   eq: 'Cu + HCl — реакция не идёт',     type: 'Нет реакции', fx: { none: true },
      why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
    { r: ['Cu','H2SO4'], eq: 'Cu + H₂SO₄(разб.) — нет реакции',type: 'Нет реакции', fx: { none: true },
      why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
  ];

  /* ── Ряд активности металлов ─────────────────────────────────── */
  static ACTIVITY_SERIES = [
    { sym: 'K',  name: 'Калий' },
    { sym: 'Na', name: 'Натрий' },
    { sym: 'Ca', name: 'Кальций' },
    { sym: 'Mg', name: 'Магний' },
    { sym: 'Al', name: 'Алюминий' },
    { sym: 'Zn', name: 'Цинк' },
    { sym: 'Fe', name: 'Железо' },
    { sym: 'Ni', name: 'Никель' },
    { sym: 'Sn', name: 'Олово' },
    { sym: 'Pb', name: 'Свинец' },
    { sym: 'H₂', name: 'Водород' },
    { sym: 'Cu', name: 'Медь' },
    { sym: 'Ag', name: 'Серебро' },
    { sym: 'Au', name: 'Золото' },
  ];

  static PRESETS = {
    neutralization: ['HCl', 'NaOH'],
    gas_evolution:  ['Na2CO3', 'HCl'],
    precipitate:    ['AgNO3', 'NaCl'],
    displacement:   ['CuSO4', 'Fe'],
    indicator:      ['Phenolphthalein', 'NaOH'],
    violent:        ['Na', 'H2O'],
    yellow_precip:  ['Pb(NO3)2', 'K2CrO4'],
    blue_precip:    ['CuSO4', 'NaOH'],
  };

  /* ── Конструктор ─────────────────────────────────────────────── */

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

    this.mixContents  = [];
    this.lastReaction = null;
    this.filterCat    = 'all';

    // liquid state
    this._liqColor       = null;
    this._liqTargetColor = null;
    this._liqLevel       = 0;
    this._precipLevel    = 0;
    this._precipColor    = null;

    // particles
    this._bubbles     = [];
    this._precipParts = [];
    this._steamParts  = [];
    this._sparkParts  = [];
    this._pourDrops   = [];    // pour animation

    // animation
    this._raf       = null;
    this._last      = 0;
    this._time      = 0;
    this._animPhase = 0;
    this._reacTimer = 0;

    // flask geometry cache
    this._g = {};

    // glow / waves
    this._glowPulse = 0;
    this._heatGlow  = 0;
    this._wave  = 0;
    this._wave2 = 0;
    this._wave3 = 0;

    // gas label
    this._gasLabel = null;

    // pour animation state
    this._pouring    = false;
    this._pourColor  = null;
    this._pourTimer  = 0;

    // drag state
    this._drag = null;   // { formula, x, y }

    // added items chips (for removal)
    this._chipLayout = []; // [{formula, x, y, w, h}]

    // shelf layout cache
    this._shelfKeys    = [];
    this._shelfStartX  = 0;
    this._shelfBottleW = 0;
    this._shelfGap     = 0;
    this._shelfBottleY = 0;
    this._shelfBottleH = 0;
    this._shelfLayout  = [];     // [{formula, x, y, w, h}]
    this._shelfScroll  = 0;      // horizontal scroll offset
    this._shelfHover   = -1;     // hovered card index

    // pending preset timeouts
    this._presetTimers = [];

    // quiz mode
    this._quizMode     = false;
    this._quizTask     = null;    // { rx, question, answer: [f1,f2] }
    this._quizScore    = 0;
    this._quizTotal    = 0;
    this._quizResult   = null;    // 'correct' | 'wrong' | null
    this._quizResultT  = 0;

    // edu-tooltip state
    this._eduTooltipAge  = -1;   // -1 = inactive; 0..1 = active
    this._eduTooltipLines = [];
    this._showHints      = true; // can be toggled

    // product label animation state
    this._prodLabelAge  = -1;
    this._prodLabelText = '';
    this._prodLabelType = 'gas';

    this.onUpdate = null;
    this.onQuizUpdate = null;     // callback(quizInfo)
    this.fit();
  }

  /* ── Геометрия колбы Эрленмейера ─────────────────────────────── */

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

  _calcGeom() {
    const { W, H } = this;
    // flask parameters (Erlenmeyer)
    const r    = Math.min(W * 0.17, H * 0.18);   // body radius (smaller to fit info)
    const cx   = W * 0.50;
    const cy   = H * 0.34;                         // body center (higher)
    const nw   = r * 0.22;                         // neck half-width
    const nh   = r * 0.90;                         // neck height
    const nt   = cy - r - nh;                       // neck top Y
    const nb   = cy - r * 0.75;                     // neck-shoulder transition Y
    const liqTop = cy - r * 0.35;                   // default liquid surface
    // shelf — 2-row grid with large cards
    const shelfH = 140;
    const shelfY = H - shelfH - 4;
    this._g = { r, cx, cy, nw, nh, nt, nb, liqTop, shelfY, shelfH };
  }

  _flaskPath(ctx) {
    const { r, cx, cy, nw, nt, nb } = this._g;
    ctx.beginPath();
    ctx.moveTo(cx - nw, nt);
    ctx.lineTo(cx - nw, nb);
    ctx.bezierCurveTo(cx - nw, cy - r * 0.38, cx - r * 0.85, cy - r * 0.08, cx - r, cy);
    ctx.arc(cx, cy, r, Math.PI, 0, true);
    ctx.bezierCurveTo(cx + r * 0.85, cy - r * 0.08, cx + nw, cy - r * 0.38, cx + nw, nb);
    ctx.lineTo(cx + nw, nt);
    ctx.closePath();
  }

  /* ── Запуск / остановка ─────────────────────────────────────── */

  start() {
    if (this._raf) return;
    this._last = performance.now();
    const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
    this._raf  = requestAnimationFrame(loop);
  }

  stop() { cancelAnimationFrame(this._raf); this._raf = null; }

  /* ── Публичный API ──────────────────────────────────────────── */

  reset() {
    // cancel any pending preset timers
    for (const t of this._presetTimers) clearTimeout(t);
    this._presetTimers   = [];

    this.mixContents     = [];
    this.lastReaction    = null;
    this._liqColor       = null;
    this._liqTargetColor = null;
    this._liqLevel       = 0;
    this._precipLevel    = 0;
    this._precipColor    = null;
    this._bubbles        = [];
    this._precipParts    = [];
    this._steamParts     = [];
    this._sparkParts     = [];
    this._pourDrops      = [];
    this._animPhase      = 0;
    this._reacTimer      = 0;
    this._heatGlow       = 0;
    this._gasLabel       = null;
    this._pouring        = false;
    this._chipLayout     = [];
    this._fireInfo();
  }

  resetReaction() {
    // keep reagents in the zone, but clear reaction effects
    this.lastReaction    = null;
    this._liqTargetColor = null;
    this._animPhase      = 0;
    this._reacTimer      = 0;
    this._gasLabel       = null;
    this._bubbles        = [];
    this._precipParts    = [];
    this._steamParts     = [];
    this._sparkParts     = [];
    this._precipLevel    = 0;
    this._precipColor    = null;
    this._heatGlow       = 0;
    // recalc liquid to original colors without reaction effects
    this._recalcLiquid();
    this._fireInfo();
  }

  addToMix(formula) {
    if (this.mixContents.length >= 4) return;
    if (this.mixContents.includes(formula)) return;

    this.mixContents.push(formula);
    const sub = ChemSandboxSim.SUBSTANCES[formula];

    if (window.LabFX) {
      LabFX.sound.play('pour');
      const { cx, nt, nw } = this._g;
      LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 15, color: sub.color,
        speed: 60, spread: 1.8, angle: Math.PI / 2, gravity: 300, life: 800, shape: 'splash' });
    }

    // pour animation
    this._pouring   = true;
    this._pourColor = sub.color;
    this._pourTimer = 0;
    this._spawnPourDrops(sub.color, sub.state === 's');

    // liquid level (cap at 0.85 to keep within flask body)
    if (sub.state !== 's') {
      this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
      this._liqColor = this._liqColor
        ? this._blendColors(this._liqColor, sub.color, 0.45)
        : sub.color;
    } else {
      this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
    }

    // check reaction
    this._checkReaction();
    this._fireInfo();
  }

  removeFromMix(formula) {
    const idx = this.mixContents.indexOf(formula);
    if (idx === -1) return;
    this.mixContents.splice(idx, 1);

    // recalculate liquid
    this._recalcLiquid();

    // re-check reaction
    this.lastReaction    = null;
    this._animPhase      = 0;
    this._gasLabel       = null;
    this._bubbles        = [];
    this._precipParts    = [];
    this._steamParts     = [];
    this._sparkParts     = [];
    this._precipLevel    = 0;
    this._precipColor    = null;
    this._heatGlow       = 0;
    this._liqTargetColor = null;

    if (this.mixContents.length >= 2) this._checkReaction();
    this._fireInfo();
  }

  _recalcLiquid() {
    this._liqLevel = 0;
    this._liqColor = null;
    for (const f of this.mixContents) {
      const s = ChemSandboxSim.SUBSTANCES[f];
      if (s.state !== 's') {
        this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
        this._liqColor = this._liqColor
          ? this._blendColors(this._liqColor, s.color, 0.45)
          : s.color;
      } else {
        this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
      }
    }
  }

  setCategory(cat) { this.filterCat = cat; }

  preset(name) {
    const p = ChemSandboxSim.PRESETS[name];
    if (!p) return;
    this.reset();
    // add with staggered delay (store timer IDs so reset can cancel them)
    this._presetTimers = p.map((f, i) =>
      setTimeout(() => this.addToMix(f), i * 600)
    );
  }

  info() {
    const r = this.lastReaction;
    return {
      mixed:    this.mixContents.length,
      contents: [...this.mixContents],
      reaction: r && !r.fx.none ? true : false,
      type:     r ? r.type : null,
      equation: r ? r.eq : null,
      products: r && !r.fx.none ? this._productsStr(r) : null,
      ionFull:  r ? r.ionFull || null : null,
      ionNet:   r ? r.ionNet  || null : null,
      why:      r ? r.why || null : null,
    };
  }

  /* ── Поиск реакции ─────────────────────────────────────────── */

  _checkReaction() {
    const c = this.mixContents;
    if (c.length < 2) return;
    for (let i = 0; i < c.length; i++) {
      for (let j = i + 1; j < c.length; j++) {
        const rx = this._findReaction(c[i], c[j]);
        if (rx) {
          this.lastReaction = rx;
          this._triggerReaction(rx);
          if (this._quizMode) this._checkQuizAnswer();
          return;
        }
      }
    }
  }

  _findReaction(a, b) {
    return ChemSandboxSim.REACTIONS.find(rx =>
      (rx.r[0] === a && rx.r[1] === b) || (rx.r[0] === b && rx.r[1] === a)
    ) || null;
  }

  _productsStr(rx) {
    const parts = rx.eq.split('<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>');
    return parts.length > 1 ? parts[1].trim() : '—';
  }

  /* ── Эффекты реакции ────────────────────────────────────────── */

  _triggerReaction(rx) {
    const fx = rx.fx;
    if (fx.none) { this._fireInfo(); return; }

    this._animPhase = 1;
    this._reacTimer = 0;

    if (fx.colorTo)  this._liqTargetColor = fx.colorTo;
    if (fx.gas)      { this._gasLabel = fx.gas; this._spawnBubbles(fx.violent ? 45 : 22); }
    if (fx.precip)   { this._precipColor = fx.precip.c; this._spawnPrecipitate(28); }
    if (fx.heat)     {
      this._heatGlow = 1.0;
      this._spawnSteam(fx.violent ? 25 : 12);
      if (fx.violent) this._spawnSparks(35);
    }

    /* product label animation */
    if (fx.gas && window.ChemVisuals) {
      this._prodLabelText = fx.gas + ' ';
      this._prodLabelType = 'gas';
      this._prodLabelAge  = 0;
    } else if (fx.precip && window.ChemVisuals) {
      this._prodLabelText = (fx.precip.n || '') + ' ';
      this._prodLabelType = 'precip';
      this._prodLabelAge  = 0;
    }

    /* educational tooltip */
    if (rx.why && this._showHints && window.ChemVisuals) {
      const typeLabel = rx.type ? 'Тип: ' + rx.type : '';
      const why = rx.why.replace(/<[^>]+>/g, ''); /* strip SVG tags */
      this._eduTooltipLines = [
        typeLabel,
        ...why.split(' ').reduce((acc, w) => {
          const last = acc[acc.length - 1];
          if (last && (last + ' ' + w).length < 32) {
            acc[acc.length - 1] = last + ' ' + w;
          } else {
            acc.push(w);
          }
          return acc;
        }, []),
      ].filter(Boolean).slice(0, 4);
      this._eduTooltipAge = 0;
    }

    if (window.LabFX) {
      const { cx, cy } = this._g;
      if (fx.violent) {
        // Combustion-like: flame sparks + shake
        LabFX.sound.play('fizz');
        LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 20, count: 30,
          color: '#FFA500', speed: 90, spread: 2.5, angle: -Math.PI / 2,
          gravity: -100, life: 600, shape: 'spark', glow: true });
        LabFX.shake(this.canvas, { intensity: 3, durMs: 300 });
      } else if (fx.gas) {
        // Gas evolution: fizz + rising bubbles
        LabFX.sound.play('fizz');
        LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 10,
          color: '#FFFFFF', speed: 40, spread: 1.2, angle: -Math.PI / 2,
          gravity: -80, life: 1200, shape: 'ring' });
      } else if (fx.precip) {
        // Precipitate: settling dust
        LabFX.sound.play('fizz');
        LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 10, count: 12,
          color: fx.precip.c || '#888888', speed: 20, spread: 1.5, angle: Math.PI / 2,
          gravity: 60, life: 1500, shape: 'dust' });
      } else {
        LabFX.sound.play('fizz');
      }
    }

    this._fireInfo();
  }

  /* ── Частицы ────────────────────────────────────────────────── */

  _spawnPourDrops(color, isSolid) {
    const { cx, nt, nw } = this._g;
    const n = isSolid ? 8 : 15;
    for (let i = 0; i < n; i++) {
      this._pourDrops.push({
        x: cx + (Math.random() - 0.5) * nw * 1.5,
        y: nt - 20 - Math.random() * 30,
        r: isSolid ? 2 + Math.random() * 3 : 1.5 + Math.random() * 2,
        vy: 1.5 + Math.random() * 3,
        vx: (Math.random() - 0.5) * 0.8,
        color,
        life: 1.0,
        solid: isSolid,
      });
    }
  }

  _spawnBubbles(n) {
    const { cx, cy, r } = this._g;
    for (let i = 0; i < n; i++) {
      const angle = (Math.random() - 0.5) * 1.2;
      const dist  = Math.random() * r * 0.6;
      this._bubbles.push({
        x: cx + Math.sin(angle) * dist,
        y: cy + r * 0.3 - Math.random() * 15,
        r: 1.5 + Math.random() * 4.5,
        vy: -(1.5 + Math.random() * 3),
        vx: (Math.random() - 0.5) * 0.8,
        life: 1.0,
        delay: Math.random() * 2.5,
      });
    }
  }

  _spawnPrecipitate(n) {
    const { cx, cy, r } = this._g;
    for (let i = 0; i < n; i++) {
      this._precipParts.push({
        x: cx + (Math.random() - 0.5) * r * 1.2,
        y: cy - r * 0.2 + Math.random() * r * 0.4,
        r: 1.5 + Math.random() * 3,
        vy: 0.3 + Math.random() * 0.9,
        vx: (Math.random() - 0.5) * 0.3,
        life: 1.0,
        delay: Math.random() * 1.8,
      });
    }
  }

  _spawnSteam(n) {
    const { cx, nt, nw } = this._g;
    for (let i = 0; i < n; i++) {
      this._steamParts.push({
        x: cx + (Math.random() - 0.5) * nw * 2,
        y: nt - 5 + Math.random() * 10,
        r: 3 + Math.random() * 7,
        vy: -(0.5 + Math.random() * 1.8),
        vx: (Math.random() - 0.5) * 0.6,
        life: 1.0,
        delay: Math.random() * 2.5,
      });
    }
  }

  _spawnSparks(n) {
    const { cx, nt, nw } = this._g;
    for (let i = 0; i < n; i++) {
      const a = Math.random() * Math.PI * 2;
      const sp = 2 + Math.random() * 5;
      this._sparkParts.push({
        x: cx + (Math.random() - 0.5) * nw,
        y: nt + 5,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - 3,
        life: 1.0,
        delay: Math.random() * 1.0,
      });
    }
  }

  /* ── Тик ────────────────────────────────────────────────────── */

  _tick(now) {
    const dt = Math.min((now - this._last) / 1000, 0.05);
    this._last = now;
    this._time += dt;
    this._wave  += dt * 1.7;
    this._wave2 += dt * 2.3;
    this._wave3 += dt * 0.88;
    this._glowPulse += dt * 3.2;

    if (window.LabFX) LabFX.particles.update(dt);

    if (this._animPhase === 1) {
      this._reacTimer += dt;
      if (this._reacTimer > 5.0) this._animPhase = 2;
    }

    // color lerp
    if (this._liqTargetColor && this._liqColor) {
      this._liqColor = this._lerpColor(this._liqColor, this._liqTargetColor, dt * 1.0);
    }

    if (this._heatGlow > 0) this._heatGlow = Math.max(0, this._heatGlow - dt * 0.12);

    // pour animation
    if (this._pouring) {
      this._pourTimer += dt;
      if (this._pourTimer > 1.2) this._pouring = false;
    }

    // quiz result timer
    if (this._quizResultT > 0) {
      this._quizResultT -= dt;
      if (this._quizResultT <= 0) {
        this._quizResultT = 0;
        if (this._quizResult === 'correct') this._nextQuizTask();
      }
    }

    /* advance edu-tooltip age (total lifespan = 4s) */
    if (this._eduTooltipAge >= 0) {
      this._eduTooltipAge += dt / 4.0;
      if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1;
    }

    /* advance product label age (total lifespan = 3s) */
    if (this._prodLabelAge >= 0) {
      this._prodLabelAge += dt / 3.0;
      if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
    }

    this._updatePour(dt);
    this._updateBubbles(dt);
    this._updatePrecip(dt);
    this._updateSteam(dt);
    this._updateSparks(dt);

    this.draw();
  }

  _updatePour(dt) {
    const { cy, r } = this._g;
    const surfY = this._getSurfaceY();
    for (let i = this._pourDrops.length - 1; i >= 0; i--) {
      const d = this._pourDrops[i];
      d.vy += 8 * dt; // gravity
      d.y += d.vy;
      d.x += d.vx;
      if (d.y > surfY) {
        d.life -= dt * 3;
      }
      if (d.life <= 0 || d.y > cy + r) this._pourDrops.splice(i, 1);
    }
  }

  _updateBubbles(dt) {
    const surfY = this._getSurfaceY();
    for (let i = this._bubbles.length - 1; i >= 0; i--) {
      const b = this._bubbles[i];
      if (b.delay > 0) { b.delay -= dt; continue; }
      b.x += b.vx;
      b.y += b.vy;
      b.vx += (Math.random() - 0.5) * 0.4;
      b.life -= dt * 0.22;
      if (b.y < surfY - 8 || b.life <= 0) this._bubbles.splice(i, 1);
    }
    if (this._animPhase === 1 && this._gasLabel && Math.random() < 0.35) {
      this._spawnBubbles(2);
    }
  }

  _updatePrecip(dt) {
    const { cy, r } = this._g;
    const bottom = cy + r - 14;
    for (let i = this._precipParts.length - 1; i >= 0; i--) {
      const p = this._precipParts[i];
      if (p.delay > 0) { p.delay -= dt; continue; }
      p.x += p.vx;
      p.y += p.vy;
      p.vx *= 0.99;
      if (p.y >= bottom) {
        p.y = bottom; p.vy = 0; p.vx = 0;
        this._precipLevel = Math.min(1, this._precipLevel + 0.004);
        p.life -= dt * 0.25;
      }
      if (p.life <= 0) this._precipParts.splice(i, 1);
    }
  }

  _updateSteam(dt) {
    for (let i = this._steamParts.length - 1; i >= 0; i--) {
      const s = this._steamParts[i];
      if (s.delay > 0) { s.delay -= dt; continue; }
      s.x += s.vx; s.y += s.vy;
      s.r += dt * 1.8; s.life -= dt * 0.35;
      if (s.life <= 0) this._steamParts.splice(i, 1);
    }
  }

  _updateSparks(dt) {
    for (let i = this._sparkParts.length - 1; i >= 0; i--) {
      const s = this._sparkParts[i];
      if (s.delay > 0) { s.delay -= dt; continue; }
      s.x += s.vx; s.y += s.vy;
      s.vy += 4 * dt;
      s.life -= dt * 0.7;
      if (s.life <= 0) this._sparkParts.splice(i, 1);
    }
  }

  _getSurfaceY() {
    const { cy, r, nt, nb } = this._g;
    if (this._liqLevel <= 0) return cy + r;
    // total fillable height: from flask bottom (cy+r) up to just below neck-shoulder (nb+4)
    const maxH = (cy + r) - (nb + 4);
    const liqH = maxH * Math.min(1, this._liqLevel) * 0.75;
    return cy + r - liqH;
  }

  /* ── Рендеринг ─────────────────────────────────────────────── */

  draw() {
    const { ctx, W, H } = this;
    ctx.clearRect(0, 0, W, H);

    this._drawBackground();
    this._drawFlaskShadow();
    this._drawLiquid();
    this._drawPrecipitate();
    this._drawBubbles();
    this._drawPourDrops();
    this._drawFlaskGlass();
    this._drawSteam();
    this._drawSparks();
    this._drawChips();
    this._drawEquation();
    this._drawShelf();
    this._drawDragGhost();
    if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint();
    if (this._quizMode) this._drawQuizOverlay();
    if (window.LabFX) LabFX.particles.draw(ctx);
    /* edu-tooltip overlay */
    if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) {
      const { cx, nt } = this._g;
      ChemVisuals.drawEduTooltip(ctx, cx, nt - 20, 200, this._eduTooltipLines, this._eduTooltipAge);
    }
  }

  _drawBackground() {
    const { ctx, W, H } = this;
    const { cy, r } = this._g;
    const bg = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.75);
    bg.addColorStop(0, '#0c0c1a');
    bg.addColorStop(1, '#050508');
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);
    // grid
    ctx.strokeStyle = 'rgba(255,255,255,0.02)';
    ctx.lineWidth = 0.5;
    for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
    for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
    /* desk surface behind flask */
    if (window.ChemVisuals) {
      const tableY = cy + r + 6;
      ChemVisuals.drawDeskBackground(ctx, W, H, tableY);
    }
  }

  _drawFlaskShadow() {
    const { ctx } = this;
    const { cx, cy, r } = this._g;
    if (window.ChemVisuals) {
      ChemVisuals.drawVesselShadow(ctx, cx, cy + r + 4, r);
    } else {
      // fallback
      const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1);
      sg.addColorStop(0, 'rgba(0,0,0,0.25)');
      sg.addColorStop(1, 'rgba(0,0,0,0)');
      ctx.fillStyle = sg;
      ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25);
    }
  }

  _drawLiquid() {
    if (this._liqLevel <= 0) return;
    const { ctx } = this;
    const g = this._g;
    const surfY = this._getSurfaceY();

    ctx.save();
    this._flaskPath(ctx);
    ctx.clip();

    const col = this._liqColor || '#6EB4D7';
    const liqGrad = ctx.createLinearGradient(0, surfY, 0, g.cy + g.r);
    liqGrad.addColorStop(0, this._alphaColor(col, 0.45));
    liqGrad.addColorStop(0.5, this._alphaColor(col, 0.60));
    liqGrad.addColorStop(1, this._alphaColor(col, 0.75));
    ctx.fillStyle = liqGrad;

    // wavy surface
    ctx.beginPath();
    ctx.moveTo(g.cx - g.r - 5, g.cy + g.r + 5);
    const amp = 2.0;
    const left = g.cx - g.r - 5, right = g.cx + g.r + 5;
    for (let px = left; px <= right; px += 2) {
      const t = (px - left) / (right - left);
      const wy = surfY + Math.sin(t * 7 + this._wave) * amp
                       + Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
      ctx.lineTo(px, wy);
    }
    ctx.lineTo(right, g.cy + g.r + 5);
    ctx.closePath();
    ctx.fill();

    // meniscus highlight
    ctx.beginPath();
    for (let px = left; px <= right; px += 2) {
      const t = (px - left) / (right - left);
      const wy = surfY + Math.sin(t * 7 + this._wave) * amp
                       + Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
      if (px === left) ctx.moveTo(px, wy); else ctx.lineTo(px, wy);
    }
    ctx.strokeStyle = 'rgba(255,255,255,0.15)';
    ctx.lineWidth = 1;
    ctx.stroke();

    // SSS (sub-surface scattering glow)
    const ssg = ctx.createRadialGradient(g.cx, surfY + 20, 5, g.cx, surfY + 20, g.r * 0.8);
    ssg.addColorStop(0, this._alphaColor(col, 0.12));
    ssg.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = ssg;
    ctx.fillRect(left, surfY, right - left, g.r * 2);

    ctx.restore();
  }

  _drawPrecipitate() {
    const { ctx } = this;
    const { cx, cy, r } = this._g;
    if (this._precipLevel <= 0 && this._precipParts.length === 0) return;

    // everything clipped to flask shape
    ctx.save();
    this._flaskPath(ctx);
    ctx.clip();

    // settled layer at flask bottom
    if (this._precipLevel > 0 && this._precipColor) {
      const layerH = r * 0.30 * this._precipLevel;
      const layerY = cy + r - layerH;
      const pg = ctx.createLinearGradient(0, layerY, 0, cy + r);
      pg.addColorStop(0, this._alphaColor(this._precipColor, 0.35));
      pg.addColorStop(1, this._alphaColor(this._precipColor, 0.65));
      ctx.fillStyle = pg;
      ctx.fillRect(cx - r - 2, layerY, r * 2 + 4, layerH);
    }

    // falling particles (also clipped)
    for (const p of this._precipParts) {
      if (p.delay > 0) continue;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fillStyle = this._alphaColor(this._precipColor || '#FFF', p.life * 0.65);
      ctx.fill();
    }

    ctx.restore();

    /* precipitate product label with animated arrow */
    if (window.ChemVisuals && this._prodLabelType === 'precip' && this._prodLabelAge >= 0) {
      ChemVisuals.drawProductLabel(ctx, cx, cy + r + 2, this._prodLabelText, 'precip', this._prodLabelAge);
    }
  }

  _drawBubbles() {
    const { ctx } = this;
    for (const b of this._bubbles) {
      if (b.delay > 0) continue;
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
      ctx.strokeStyle = `rgba(255,255,255,${b.life * 0.35})`;
      ctx.lineWidth = 0.7;
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.25, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,255,255,${b.life * 0.3})`;
      ctx.fill();
    }
    // gas label above neck
    if (this._gasLabel && this._bubbles.length > 0) {
      const { cx, nt } = this._g;
      if (window.ChemVisuals && this._prodLabelAge >= 0) {
        /* animated product label with arrow */
        ChemVisuals.drawProductLabel(ctx, cx, nt - 10, this._prodLabelText, 'gas', this._prodLabelAge);
        /* continuous bubble particles near neck */
        ChemVisuals.animateGasBubbles(ctx, cx, nt - 6, 'rgba(200,235,255,0.8)', this._time);
      } else {
        ctx.save();
        ctx.font = '11px "JetBrains Mono", monospace';
        ctx.fillStyle = 'rgba(255,255,255,0.45)';
        ctx.textAlign = 'center';
        ctx.fillText(this._gasLabel + ' ', cx, nt - 14);
        ctx.restore();
      }
    }
  }

  _drawPourDrops() {
    const { ctx } = this;
    for (const d of this._pourDrops) {
      ctx.beginPath();
      ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
      ctx.fillStyle = this._alphaColor(d.color, d.life * 0.7);
      ctx.fill();
    }
  }

  _drawSteam() {
    const { ctx } = this;
    for (const s of this._steamParts) {
      if (s.delay > 0) continue;
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(200,200,220,${s.life * 0.13})`;
      ctx.fill();
    }
  }

  _drawSparks() {
    const { ctx } = this;
    for (const s of this._sparkParts) {
      if (s.delay > 0) continue;
      ctx.save();
      ctx.beginPath();
      ctx.arc(s.x, s.y, 2.2, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(255,200,60,${s.life * 0.85})`;
      ctx.shadowColor = '#FFD060';
      ctx.shadowBlur = 7;
      ctx.fill();
      ctx.restore();
    }
  }

  _drawFlaskGlass() {
    const { ctx } = this;
    const g = this._g;

    // glass body
    ctx.save();
    this._flaskPath(ctx);

    // glass fill
    const gf = ctx.createLinearGradient(g.cx - g.r, g.nt, g.cx + g.r, g.cy + g.r);
    gf.addColorStop(0, 'rgba(255,255,255,0.05)');
    gf.addColorStop(0.4, 'rgba(255,255,255,0.07)');
    gf.addColorStop(1, 'rgba(255,255,255,0.03)');
    ctx.fillStyle = gf;
    ctx.fill();

    // glass outline
    ctx.strokeStyle = 'rgba(255,255,255,0.14)';
    ctx.lineWidth = 1.8;
    ctx.stroke();

    // heat glow
    if (this._heatGlow > 0) {
      ctx.shadowColor = `rgba(255,80,20,${this._heatGlow * 0.45})`;
      ctx.shadowBlur = 30;
      ctx.strokeStyle = `rgba(255,120,60,${this._heatGlow * 0.35})`;
      ctx.stroke();
      ctx.shadowBlur = 0;
    }
    ctx.restore();

    // specular highlight — left edge
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(g.cx - g.nw + 2, g.nt + 6);
    ctx.lineTo(g.cx - g.nw + 2, g.nb + 5);
    ctx.bezierCurveTo(
      g.cx - g.nw + 2, g.cy - g.r * 0.3,
      g.cx - g.r * 0.75, g.cy - g.r * 0.05,
      g.cx - g.r + 5, g.cy + g.r * 0.3
    );
    ctx.strokeStyle = 'rgba(255,255,255,0.10)';
    ctx.lineWidth = 2.5;
    ctx.stroke();
    ctx.restore();

    // neck rim
    ctx.save();
    ctx.beginPath();
    ctx.ellipse(g.cx, g.nt, g.nw + 1, 3, 0, 0, Math.PI * 2);
    ctx.strokeStyle = 'rgba(255,255,255,0.18)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.restore();

    // graduated marks on neck
    ctx.save();
    ctx.strokeStyle = 'rgba(255,255,255,0.07)';
    ctx.lineWidth = 0.5;
    for (let i = 1; i <= 3; i++) {
      const my = g.nt + (g.nb - g.nt) * i / 4;
      ctx.beginPath();
      ctx.moveTo(g.cx - g.nw + 3, my);
      ctx.lineTo(g.cx - g.nw + 10, my);
      ctx.stroke();
    }
    ctx.restore();
  }

  /* ── Чипы добавленных реагентов (с кнопкой удаления) ──────── */

  _drawChips() {
    if (this.mixContents.length === 0) return;
    const { ctx, W } = this;
    const { nt } = this._g;

    this._chipLayout = [];
    const chipH = 22, gap = 6;
    let totalW = 0;
    const labels = this.mixContents.map(f => {
      const lbl = this._shortFormula(f);
      ctx.font = 'bold 10px "JetBrains Mono", monospace';
      const w = ctx.measureText(lbl).width + 28; // padding + "×" button
      totalW += w + gap;
      return { formula: f, label: lbl, w };
    });
    totalW -= gap;

    let x = (W - totalW) / 2;
    const y = Math.max(6, nt - 32);

    for (const { formula, label, w } of labels) {
      const sub = ChemSandboxSim.SUBSTANCES[formula];

      // chip bg
      ctx.save();
      ctx.beginPath();
      this._roundRect(ctx, x, y, w, chipH, 6);
      ctx.fillStyle = 'rgba(255,255,255,0.06)';
      ctx.fill();
      ctx.strokeStyle = this._alphaColor(sub.color, 0.4);
      ctx.lineWidth = 1;
      ctx.stroke();

      // color dot
      ctx.beginPath();
      ctx.arc(x + 10, y + chipH / 2, 4, 0, Math.PI * 2);
      ctx.fillStyle = sub.color;
      ctx.fill();

      // label
      ctx.font = 'bold 10px "JetBrains Mono", monospace';
      ctx.fillStyle = 'rgba(255,255,255,0.8)';
      ctx.textAlign = 'left';
      ctx.fillText(label, x + 18, y + chipH / 2 + 3.5);

      // "×" remove button
      const xBtnX = x + w - 14;
      ctx.font = 'bold 11px sans-serif';
      ctx.fillStyle = 'rgba(255,100,100,0.6)';
      ctx.textAlign = 'center';
      ctx.fillText('×', xBtnX, y + chipH / 2 + 4);
      ctx.restore();

      this._chipLayout.push({ formula, x, y, w, h: chipH, xBtnX });
      x += w + gap;
    }
  }

  _drawEquation() {
    if (!this.lastReaction) return;
    const { ctx, W } = this;
    const { cy, r } = this._g;
    const rx = this.lastReaction;
    let y = cy + r + 14;

    ctx.save();
    ctx.textAlign = 'center';

    // ── Молекулярное уравнение ──
    ctx.font = 'bold 17px "JetBrains Mono", monospace';
    ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
    ctx.fillText(_csClean(rx.eq), W / 2, y);
    y += 22;

    // ── Тип реакции + пояснение ──
    const tp = rx.type;
    const tpColor = tp === 'Нет реакции' ? '#EF476F'
                  : tp === 'Индикатор' ? '#9B59B6'
                  : tp === 'Нейтрализация' ? '#FFD166'
                  : tp === 'Замещение' ? '#06D6E0'
                  : tp === 'Обмен' ? '#7BF5A4'
                  : tp === 'Акт. металл' ? '#EF476F' : '#aaa';
    ctx.font = 'bold 14px sans-serif';
    ctx.fillStyle = tpColor;
    ctx.fillText(tp, W / 2, y);
    y += 18;

    // why explanation
    if (rx.why) {
      ctx.font = '13px sans-serif';
      ctx.fillStyle = 'rgba(255,255,255,0.55)';
      ctx.fillText(_csClean(rx.why), W / 2, y);
      y += 17;
    }

    // ── Полное ионное уравнение ──
    if (rx.ionFull) {
      ctx.font = '13px "JetBrains Mono", monospace';
      ctx.fillStyle = 'rgba(155,200,255,0.60)';
      ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
      y += 16;
    }

    // ── Сокращённое ионное уравнение ──
    if (rx.ionNet) {
      ctx.font = 'bold 13px "JetBrains Mono", monospace';
      ctx.fillStyle = 'rgba(123,245,164,0.75)';
      ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
      y += 16;
    }

    ctx.restore();

    // ── Мини ряд активности (для замещения) ──
    if (rx.type === 'Замещение' || rx.type === 'Нет реакции' || rx.type === 'Акт. металл') {
      this._drawActivitySeries(y + 2);
    }
  }

  _drawActivitySeries(topY) {
    const { ctx, W } = this;
    const series = ChemSandboxSim.ACTIVITY_SERIES;
    const rx = this.lastReaction;
    if (!rx) return;

    // find which metals are involved
    const involved = new Set();
    for (const f of rx.r) {
      for (const m of series) {
        if (f === m.sym || f === m.sym.replace('₂', '2')) involved.add(m.sym);
      }
    }
    const metalMap = { 'Zn': 'Zn', 'Fe': 'Fe', 'Cu': 'Cu', 'Mg': 'Mg', 'Na': 'Na', 'Ag': 'Ag' };
    for (const f of rx.r) {
      if (metalMap[f]) involved.add(f);
    }
    for (const f of rx.r) {
      if (f.startsWith('Cu')) involved.add('Cu');
      if (f.startsWith('Ag')) involved.add('Ag');
      if (f.startsWith('Fe') && !f.includes('Cl')) involved.add('Fe');
    }

    if (involved.size === 0) return;

    const cellW = 28, cellH = 18, gap = 2;
    const totalW = series.length * (cellW + gap) - gap;
    const startX = (W - totalW) / 2;
    const y = topY;

    ctx.save();
    ctx.font = '8px sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.22)';
    ctx.textAlign = 'center';
    ctx.fillText('Ряд активности металлов', W / 2, y);

    const rowY = y + 5;
    for (let i = 0; i < series.length; i++) {
      const m = series[i];
      const x = startX + i * (cellW + gap);
      const isInvolved = involved.has(m.sym) || involved.has(m.sym.replace('₂', '2'));
      const isH = m.sym === 'H₂';

      ctx.beginPath();
      this._roundRect(ctx, x, rowY, cellW, cellH, 3);
      ctx.fillStyle = isInvolved ? 'rgba(6,214,224,0.15)' : isH ? 'rgba(255,200,60,0.08)' : 'rgba(255,255,255,0.03)';
      ctx.fill();
      ctx.strokeStyle = isInvolved ? 'rgba(6,214,224,0.45)' : isH ? 'rgba(255,200,60,0.20)' : 'rgba(255,255,255,0.06)';
      ctx.lineWidth = 0.7;
      ctx.stroke();

      ctx.font = isInvolved ? 'bold 9px "JetBrains Mono", monospace' : '8px "JetBrains Mono", monospace';
      ctx.fillStyle = isInvolved ? '#06D6E0' : isH ? '#FFD166' : 'rgba(255,255,255,0.35)';
      ctx.textAlign = 'center';
      ctx.fillText(m.sym, x + cellW / 2, rowY + cellH / 2 + 3);
    }

    ctx.fillStyle = 'rgba(255,255,255,0.15)';
    ctx.font = '9px sans-serif';
    ctx.fillText('← активнее', startX + 40, rowY + cellH + 10);
    ctx.fillText('менее активные →', startX + totalW - 60, rowY + cellH + 10);

    ctx.restore();
  }

  _drawShelf() {
    const { ctx, W } = this;
    const { shelfY, shelfH } = this._g;
    if (shelfH < 30) return;

    // shelf background
    ctx.save();
    ctx.fillStyle = 'rgba(255,255,255,0.012)';
    ctx.fillRect(0, shelfY - 4, W, shelfH + 8);
    ctx.strokeStyle = 'rgba(255,255,255,0.06)';
    ctx.lineWidth = 0.5;
    ctx.beginPath(); ctx.moveTo(0, shelfY - 4); ctx.lineTo(W, shelfY - 4); ctx.stroke();
    ctx.restore();

    const subs = ChemSandboxSim.SUBSTANCES;
    const keys = Object.keys(subs).filter(k =>
      this.filterCat === 'all' || subs[k].cat === this.filterCat
    );

    // 2-row grid layout
    const rows = 2;
    const bW = 68, bH = 60, gapX = 7, gapY = 6;
    const cols = Math.ceil(keys.length / rows);
    const totalW = cols * (bW + gapX) - gapX;
    const padX = 12;
    const visW = W - padX * 2;

    // clamp scroll
    const maxScroll = Math.max(0, totalW - visW);
    this._shelfScroll = Math.max(0, Math.min(maxScroll, this._shelfScroll));
    const scrollOff = -this._shelfScroll;

    const gridTop = shelfY + Math.max(2, (shelfH - (bH * rows + gapY * (rows - 1))) / 2);

    // clip to shelf region
    ctx.save();
    ctx.beginPath();
    ctx.rect(padX - 2, shelfY - 4, visW + 4, shelfH + 8);
    ctx.clip();

    // store layout for click detection
    const layoutArr = [];

    for (let i = 0; i < keys.length; i++) {
      const k = keys[i];
      const s = subs[k];
      const col = Math.floor(i / rows);
      const row = i % rows;
      const x = padX + col * (bW + gapX) + scrollOff;
      const y = gridTop + row * (bH + gapY);

      // skip if off-screen
      if (x + bW < padX - 10 || x > W - padX + 10) {
        layoutArr.push({ formula: k, x, y, w: bW, h: bH });
        continue;
      }

      const inMix = this.mixContents.includes(k);
      const isHov = this._shelfHover === i;

      // card background
      ctx.beginPath();
      this._roundRect(ctx, x, y, bW, bH, 8);
      const bgAlpha = inMix ? 0.16 : isHov ? 0.07 : 0.035;
      ctx.fillStyle = inMix
        ? `rgba(75,205,155,${bgAlpha})`
        : `rgba(255,255,255,${bgAlpha})`;
      ctx.fill();
      ctx.strokeStyle = inMix
        ? 'rgba(75,205,155,0.55)'
        : isHov ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.07)';
      ctx.lineWidth = inMix ? 1.6 : 1;
      ctx.stroke();

      // color swatch (rounded rect, not dot)
      const swX = x + 8, swY = y + 7, swW = bW - 16, swH = 14;
      ctx.beginPath();
      this._roundRect(ctx, swX, swY, swW, swH, 4);
      ctx.fillStyle = this._alphaColor(s.color, inMix ? 0.55 : 0.35);
      ctx.fill();

      // formula (main label)
      ctx.font = 'bold 12px "JetBrains Mono", monospace';
      ctx.fillStyle = inMix ? '#7BF5A4' : 'rgba(255,255,255,0.75)';
      ctx.textAlign = 'center';
      ctx.fillText(this._shortFormula(k), x + bW / 2, y + 37);

      // name
      ctx.font = '8.5px "Manrope", sans-serif';
      ctx.fillStyle = 'rgba(255,255,255,0.30)';
      ctx.fillText(s.name.substring(0, 13), x + bW / 2, y + 50);

      // in-mix badge
      if (inMix) {
        ctx.beginPath();
        ctx.arc(x + bW - 9, y + 9, 7, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(75,205,155,0.65)';
        ctx.fill();
        ctx.font = 'bold 9px sans-serif';
        ctx.fillStyle = '#fff';
        ctx.textAlign = 'center';
        ctx.fillText('✓', x + bW - 9, y + 12.5);
      }

      layoutArr.push({ formula: k, x, y, w: bW, h: bH });
    }

    ctx.restore();

    // scroll arrows if content overflows
    if (maxScroll > 0) {
      ctx.save();
      const arrowY = shelfY + shelfH / 2;
      // left arrow
      if (this._shelfScroll > 0) {
        ctx.beginPath();
        ctx.moveTo(padX - 1, arrowY);
        ctx.lineTo(padX + 7, arrowY - 8);
        ctx.lineTo(padX + 7, arrowY + 8);
        ctx.closePath();
        ctx.fillStyle = 'rgba(255,255,255,0.15)';
        ctx.fill();
      }
      // right arrow
      if (this._shelfScroll < maxScroll) {
        const rx = W - padX + 1;
        ctx.beginPath();
        ctx.moveTo(rx, arrowY);
        ctx.lineTo(rx - 8, arrowY - 8);
        ctx.lineTo(rx - 8, arrowY + 8);
        ctx.closePath();
        ctx.fillStyle = 'rgba(255,255,255,0.15)';
        ctx.fill();
      }
      ctx.restore();
    }

    this._shelfKeys    = keys;
    this._shelfLayout  = layoutArr;
    this._shelfStartX  = padX;
    this._shelfBottleW = bW;
    this._shelfGap     = gapX;
    this._shelfBottleY = gridTop;
    this._shelfBottleH = bH;
  }

  _drawDragGhost() {
    if (!this._drag) return;
    const { ctx } = this;
    const { formula, x, y } = this._drag;
    const sub = ChemSandboxSim.SUBSTANCES[formula];
    if (!sub) return;

    ctx.save();
    ctx.globalAlpha = 0.7;
    // small bottle ghost
    ctx.beginPath();
    this._roundRect(ctx, x - 18, y - 22, 36, 44, 5);
    ctx.fillStyle = 'rgba(75,205,155,0.15)';
    ctx.fill();
    ctx.strokeStyle = 'rgba(75,205,155,0.5)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(x, y - 8, 5, 0, Math.PI * 2);
    ctx.fillStyle = sub.color;
    ctx.fill();
    ctx.font = '9px "JetBrains Mono", monospace';
    ctx.fillStyle = '#7BF5A4';
    ctx.textAlign = 'center';
    ctx.fillText(this._shortFormula(formula), x, y + 10);
    ctx.globalAlpha = 1;
    ctx.restore();
  }

  _drawHint() {
    const { ctx, W } = this;
    const { cy } = this._g;
    ctx.save();
    ctx.textAlign = 'center';
    ctx.font = '14px sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.18)';
    ctx.fillText('Выберите реагенты на полке или панели', W / 2, cy);
    /* small test-tube icon (canvas-drawn, no emoji) */
    if (window.ChemVisuals) {
      ChemVisuals.drawTube(ctx, W / 2, cy - 52, 36, null);
    }
    ctx.restore();
  }

  _drawQuizOverlay() {
    if (!this._quizTask) return;
    const { ctx, W } = this;
    const { nt } = this._g;

    ctx.save();
    ctx.textAlign = 'center';

    // question banner at top
    const bannerY = Math.max(4, nt - 58);
    ctx.fillStyle = 'rgba(155,93,229,0.12)';
    ctx.beginPath();
    this._roundRect(ctx, W / 2 - 180, bannerY, 360, 24, 8);
    ctx.fill();
    ctx.strokeStyle = 'rgba(155,93,229,0.30)';
    ctx.lineWidth = 1;
    ctx.stroke();

    ctx.font = 'bold 11px "Manrope", sans-serif';
    ctx.fillStyle = '#C9A0FF';
    ctx.fillText(this._quizTask.question, W / 2, bannerY + 15);

    // score
    ctx.font = '9px sans-serif';
    ctx.fillStyle = 'rgba(255,255,255,0.35)';
    ctx.textAlign = 'right';
    ctx.fillText(`${this._quizScore}/${this._quizTotal}`, W - 12, bannerY + 14);

    // result flash
    if (this._quizResult && this._quizResultT > 0) {
      const alpha = Math.min(1, this._quizResultT / 0.5);
      const isOk = this._quizResult === 'correct';
      ctx.textAlign = 'center';
      ctx.font = 'bold 18px "Manrope", sans-serif';
      ctx.fillStyle = isOk
        ? `rgba(123,245,164,${alpha * 0.9})`
        : `rgba(239,71,111,${alpha * 0.9})`;
      ctx.fillText(isOk ? 'Верно!' : 'Неверно', W / 2, bannerY + 50);
      if (!isOk && this._quizTask) {
        ctx.font = '10px "JetBrains Mono", monospace';
        ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
        ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65);
      }
    }

    ctx.restore();
  }

  /* ── Мышь ──────────────────────────────────────────────────── */

  _hitShelfCard(mx, my) {
    if (!this._shelfLayout) return null;
    for (let i = 0; i < this._shelfLayout.length; i++) {
      const c = this._shelfLayout[i];
      if (mx >= c.x && mx <= c.x + c.w && my >= c.y && my <= c.y + c.h) return i;
    }
    return null;
  }

  handleClick(e) {
    const rect = this.canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;

    // check chip "×" buttons first
    for (const chip of this._chipLayout) {
      if (mx >= chip.xBtnX - 8 && mx <= chip.xBtnX + 8 && my >= chip.y && my <= chip.y + chip.h) {
        this.removeFromMix(chip.formula);
        return;
      }
    }

    // check shelf cards (2-row grid)
    const idx = this._hitShelfCard(mx, my);
    if (idx !== null && this._shelfLayout[idx]) {
      this.addToMix(this._shelfLayout[idx].formula);
      return;
    }
  }

  handleMouseDown(e) {
    if (e.button !== 0) return;
    const rect = this.canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;

    const idx = this._hitShelfCard(mx, my);
    if (idx !== null && this._shelfLayout[idx]) {
      this._drag = { formula: this._shelfLayout[idx].formula, x: mx, y: my, startX: mx, startY: my };
    }
  }

  handleMouseMove(e) {
    const rect = this.canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;

    if (this._drag) {
      this._drag.x = mx;
      this._drag.y = my;
      return;
    }

    // hover detection
    const idx = this._hitShelfCard(mx, my);
    this._shelfHover = idx !== null ? idx : -1;
  }

  handleMouseUp(e) {
    if (!this._drag) return;
    const d = this._drag;
    this._drag = null;

    // if dragged into flask area — add
    const { cx, cy, r, nt } = this._g;
    const dx = d.x - cx, dy = d.y - cy;
    const inFlask = (dy > nt - cy - 20) && (dx * dx + dy * dy < (r + 30) * (r + 30));
    const movedEnough = Math.abs(d.x - d.startX) + Math.abs(d.y - d.startY) > 10;

    if (inFlask && movedEnough) {
      this.addToMix(d.formula);
    }
  }

  handleWheel(e) {
    const rect = this.canvas.getBoundingClientRect();
    const my = e.clientY - rect.top;
    const { shelfY, shelfH } = this._g;
    // only scroll when cursor is over shelf area
    if (my >= shelfY - 10 && my <= shelfY + shelfH + 10) {
      e.preventDefault();
      this._shelfScroll += e.deltaY * 0.8;
    }
  }

  handleContextMenu(e) {
    e.preventDefault();
    if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
    this.reset();
  }

  /* ── Режим заданий (квиз) ──────────────────────────────────── */

  startQuiz() {
    this._quizMode  = true;
    this._quizScore = 0;
    this._quizTotal = 0;
    this.filterCat  = 'all'; // show all reagents so quiz tasks are always solvable
    this._nextQuizTask();
    this._fireQuizInfo();
  }

  stopQuiz() {
    this._quizMode   = false;
    this._quizTask   = null;
    this._quizResult = null;
    this.reset();
    this._fireQuizInfo();
  }

  _nextQuizTask() {
    this.reset();
    // pick a random non-indicator, non-"no reaction" reaction
    const pool = ChemSandboxSim.REACTIONS.filter(rx =>
      rx.type !== 'Индикатор' && rx.type !== 'Нет реакции'
    );
    const rx = pool[Math.floor(Math.random() * pool.length)];
    const questions = this._generateQuestions(rx);
    const q = questions[Math.floor(Math.random() * questions.length)];
    this._quizTask   = { rx, question: q, answer: [...rx.r] };
    this._quizResult = null;
    this._fireQuizInfo();
  }

  _generateQuestions(rx) {
    const prods = this._productsStr(rx);
    const questions = [];
    if (rx.fx.precip) {
      questions.push(`Получи осадок ${rx.fx.precip.n}`);
    }
    if (rx.fx.gas) {
      questions.push(`Получи газ ${rx.fx.gas}`);
    }
    questions.push(`Проведи реакцию: ${_csClean(prods)}`);
    if (rx.type === 'Нейтрализация') {
      questions.push('Проведи реакцию нейтрализации');
    }
    if (rx.type === 'Замещение') {
      questions.push('Проведи реакцию замещения');
    }
    return questions;
  }

  _checkQuizAnswer() {
    if (!this._quizMode || !this._quizTask) return;
    const needed = this._quizTask.answer;
    const has = this.mixContents;
    if (has.length < 2) return;

    // check if the correct reagents are present
    const correct = needed.every(f => has.includes(f));
    this._quizTotal++;
    if (correct) {
      this._quizScore++;
      this._quizResult = 'correct';
    } else {
      this._quizResult = 'wrong';
    }
    this._quizResultT = 2.5; // seconds to show result
    this._fireQuizInfo();
  }

  _fireQuizInfo() {
    if (this.onQuizUpdate) {
      this.onQuizUpdate({
        active:   this._quizMode,
        question: this._quizTask ? this._quizTask.question : null,
        score:    this._quizScore,
        total:    this._quizTotal,
        result:   this._quizResult,
        answer:   this._quizTask ? _csClean(this._quizTask.rx.eq) : null,
      });
    }
  }

  /* ── Утилиты ───────────────────────────────────────────────── */

  _roundRect(ctx, x, y, w, h, rad) {
    ctx.moveTo(x + rad, y);
    ctx.lineTo(x + w - rad, y);
    ctx.quadraticCurveTo(x + w, y, x + w, y + rad);
    ctx.lineTo(x + w, y + h - rad);
    ctx.quadraticCurveTo(x + w, y + h, x + w - rad, y + h);
    ctx.lineTo(x + rad, y + h);
    ctx.quadraticCurveTo(x, y + h, x, y + h - rad);
    ctx.lineTo(x, y + rad);
    ctx.quadraticCurveTo(x, y, x + rad, y);
  }

  _alphaColor(hex, alpha) {
    if (!hex || !hex.startsWith('#')) return `rgba(128,128,128,${alpha})`;
    const rv = parseInt(hex.slice(1, 3), 16) || 0;
    const gv = parseInt(hex.slice(3, 5), 16) || 0;
    const bv = parseInt(hex.slice(5, 7), 16) || 0;
    return `rgba(${rv},${gv},${bv},${alpha})`;
  }

  _blendColors(c1, c2, t) {
    const h = (s) => s.startsWith('#') ? s.slice(1) : '888888';
    const p = (s, o) => parseInt(s.slice(o, o + 2), 16) || 0;
    const h1 = h(c1), h2 = h(c2);
    const rv = Math.round(p(h1, 0) * (1 - t) + p(h2, 0) * t);
    const gv = Math.round(p(h1, 2) * (1 - t) + p(h2, 2) * t);
    const bv = Math.round(p(h1, 4) * (1 - t) + p(h2, 4) * t);
    return '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2, '0')).join('');
  }

  _lerpColor(from, to, t) {
    const safe = c => (c && c.startsWith('#')) ? c : '#6EB4D7';
    return this._blendColors(safe(from), safe(to), Math.min(1, t));
  }

  _shortFormula(key) {
    const map = {
      'CH3COOH': 'CH₃COOH', 'Ca(OH)2': 'Ca(OH)₂', 'NH3·H2O': 'NH₃·H₂O',
      'H2SO4': 'H₂SO₄', 'HNO3': 'HNO₃', 'Na2CO3': 'Na₂CO₃',
      'CuSO4': 'CuSO₄', 'BaCl2': 'BaCl₂', 'AgNO3': 'AgNO₃',
      'FeCl3': 'FeCl₃', 'Pb(NO3)2': 'Pb(NO₃)₂', 'K2CrO4': 'K₂CrO₄',
      'H2O': 'H₂O', 'Phenolphthalein': 'ФФт', 'Litmus': 'Лакм.',
      'MethylOrange': 'МетОр.',
    };
    return map[key] || key;
  }

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

/* ─── lab UI init ─────────────────────────────────── */
  function _openChemSandbox() {
    document.getElementById('sim-topbar-title').textContent = 'Химическая песочница';
    _simShow('sim-chemsandbox');
    _simShow('ctrl-chemsandbox');

    requestAnimationFrame(() => requestAnimationFrame(() => {
      const c = document.getElementById('chemsandbox-canvas');
      if (!chemSandSim) {
        chemSandSim = new ChemSandboxSim(c);
        chemSandSim.onUpdate = _chemSandUpdateUI;
        chemSandSim.onQuizUpdate = _chemSandQuizUI;
        c.addEventListener('click', e => chemSandSim.handleClick(e));
        c.addEventListener('mousedown', e => chemSandSim.handleMouseDown(e));
        c.addEventListener('mousemove', e => chemSandSim.handleMouseMove(e));
        c.addEventListener('mouseup', e => chemSandSim.handleMouseUp(e));
        c.addEventListener('wheel', e => chemSandSim.handleWheel(e), { passive: false });
        c.addEventListener('contextmenu', e => chemSandSim.handleContextMenu(e));
        _addTouchSupport(c, chemSandSim);
        _chemSandBuildReagents('all');
      }
      chemSandSim.fit();
      chemSandSim.start();
      chemSandSim.draw();
    }));
  }

  function chemSandCat(cat, el) {
    document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
    el.classList.add('active');
    if (chemSandSim) chemSandSim.setCategory(cat);
    _chemSandBuildReagents(cat);
    if (chemSandSim) chemSandSim.draw();
  }

  function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
  function chemSandReset()         { if (chemSandSim) { if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 }); chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
  function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }

  function chemSandConcChange() {
    const v = +document.getElementById('sl-csand-conc').value;
    document.getElementById('csand-conc-val').textContent = v + '%';
  }
  function chemSandTempChange() {
    const v = +document.getElementById('sl-csand-temp').value;
    document.getElementById('csand-temp-val').textContent = v + '°C';
  }

  function chemSandAdd(formula) {
    if (!chemSandSim) return;
    // toggle: if already in mix — remove, else add
    if (chemSandSim.mixContents.includes(formula)) {
      chemSandSim.removeFromMix(formula);
    } else {
      if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
      chemSandSim.addToMix(formula);
    }
    _chemSandBuildReagents(chemSandSim.filterCat);
  }

  function _chemSandBuildReagents(cat) {
    const box = document.getElementById('chemsand-reagents');
    if (!box) return;
    const subs = ChemSandboxSim.SUBSTANCES;
    const keys = Object.keys(subs).filter(k => cat === 'all' || subs[k].cat === cat);
    const inMix = chemSandSim ? chemSandSim.mixContents : [];
    box.innerHTML = keys.map(k => {
      const s = subs[k];
      const active = inMix.includes(k);
      const cls = active ? 'proj-preset-chip reac-mode-btn active' : 'proj-preset-chip reac-mode-btn';
      const sf = chemSandSim ? chemSandSim._shortFormula(k) : k;
      const removeHint = active ? ' (клик — убрать)' : '';
      return `<button class="${cls}" onclick="chemSandAdd('${k}')" title="${s.name}${removeHint}" style="font-size:.68rem;padding:4px 7px">
        <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.color};margin-right:3px;vertical-align:middle"></span>${sf}${active ? ' ×' : ''}</button>`;
    }).join('');
  }

  function chemSandSetMode(mode, el) {
    document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
    if (el) el.classList.add('active');
    if (!chemSandSim) return;
    if (mode === 'quiz') {
      if (window._simQuizAllowed === false) {
        LS.toast('Режим заданий недоступен — администратор ограничил доступ', 'error');
        // revert button state
        document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
        document.getElementById('csand-mode-free')?.classList.add('active');
        return;
      }
      chemSandSim.startQuiz();
      // reset category filter to 'all' so all reagents are accessible
      document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
      const allBtn = document.querySelector('.chemsand-cat');
      if (allBtn) allBtn.classList.add('active');
      _chemSandBuildReagents('all');
    } else {
      chemSandSim.stopQuiz();
      document.getElementById('csand-quiz-question').style.display = 'none';
      document.getElementById('csand-quiz-result').style.display = 'none';
      document.getElementById('csand-quiz-next').style.display = 'none';
      document.getElementById('csand-quiz-score').textContent = '';
    }
  }

  function chemSandQuizNext() {
    if (chemSandSim && chemSandSim._quizMode) {
      chemSandSim._nextQuizTask();
      _chemSandBuildReagents(chemSandSim.filterCat);
    }
  }

  function _chemSandQuizUI(qi) {
    const qEl = document.getElementById('csand-quiz-question');
    const rEl = document.getElementById('csand-quiz-result');
    const nEl = document.getElementById('csand-quiz-next');
    const sEl = document.getElementById('csand-quiz-score');
    if (!qi.active) {
      qEl.style.display = 'none'; rEl.style.display = 'none'; nEl.style.display = 'none';
      sEl.textContent = '';
      return;
    }
    qEl.style.display = 'block';
    qEl.textContent = qi.question || '';
    sEl.textContent = qi.total > 0 ? `${qi.score}/${qi.total}` : '';
    if (qi.result) {
      rEl.style.display = 'block';
      rEl.style.color = qi.result === 'correct' ? '#7BF5A4' : '#EF476F';
      rEl.textContent = qi.result === 'correct' ? 'Верно!' : 'Неверно — ' + (qi.answer || '');
      nEl.style.display = qi.result === 'wrong' ? 'inline-block' : 'none';
    } else {
      rEl.style.display = 'none'; nEl.style.display = 'none';
    }
  }

  let _lastReportedEquation = null;
  function _chemSandUpdateUI(info) {
    document.getElementById('csbar-v1').textContent = info.mixed;
    document.getElementById('csbar-v3').textContent = info.type || '—';
    const eqEl = document.getElementById('csbar-v4');
    eqEl.innerHTML = info.equation || '—';
    eqEl.title = (info.equation || '').replace(/<[^>]*>/g, '');
    const prodEl = document.getElementById('csbar-v5');
    prodEl.innerHTML = info.products || '—';
    prodEl.title = (info.products || '').replace(/<[^>]*>/g, '');
    const ionEl = document.getElementById('csbar-v6');
    ionEl.innerHTML = info.ionNet || '—';
    ionEl.title = (info.ionNet || '').replace(/<[^>]*>/g, '');
    // rebuild reagent buttons to reflect active state
    _chemSandBuildReagents(chemSandSim ? chemSandSim.filterCat : 'all');
    // Report lab activity for gamification (once per unique reaction)
    if (info.reaction && info.equation && info.equation !== _lastReportedEquation) {
      _lastReportedEquation = info.equation;
      if (window.LS?.reportLabActivity) LS.reportLabActivity(1).catch(() => {});
    }
    // ── HTML overlay: 3-form equation display ──
    _chemSandShowEqOverlay(info);
  }

  /* equation overlay timer handle */
  let _csEqOverlayTimer = null;

  function _chemSandShowEqOverlay(info) {
    const overlay = document.getElementById('chemsand-eq-overlay');
    if (!overlay) return;

    // clear any existing hide timer
    if (_csEqOverlayTimer) { clearTimeout(_csEqOverlayTimer); _csEqOverlayTimer = null; }

    if (!info.reaction || !info.equation) {
      overlay.classList.remove('visible');
      return;
    }

    /* helpers: strip SVG arrow markup → plain text "=" */
    function _stripSvg(s) {
      if (!s) return '';
      return s.replace(/<svg[^>]*class="ic"[^>]*>[\s\S]*?<\/svg>/g, '=');
    }

    const molLine    = document.getElementById('chemsand-eq-mol');
    const fullLine   = document.getElementById('chemsand-eq-full');
    const netLine    = document.getElementById('chemsand-eq-net');
    const typeBadge  = document.getElementById('chemsand-eq-type');

    molLine.innerHTML   = _stripSvg(info.equation);
    fullLine.innerHTML  = info.ionFull ? _stripSvg(info.ionFull) : '<span style="opacity:.45">ионное уравнение недоступно</span>';
    netLine.innerHTML   = info.ionNet  ? _stripSvg(info.ionNet)  : '<span style="opacity:.45">сокращённое ионное недоступно</span>';

    const tpColor = info.type === 'Нет реакции' ? '#EF476F'
                  : info.type === 'Индикатор'    ? '#9B59B6'
                  : info.type === 'Нейтрализация'? '#FFD166'
                  : info.type === 'Замещение'    ? '#06D6E0'
                  : info.type === 'Обмен'        ? '#7BF5A4'
                  : info.type === 'Акт. металл'  ? '#EF476F'
                  : 'rgba(255,255,255,.5)';
    typeBadge.textContent  = info.type || '';
    typeBadge.style.color  = tpColor;
    typeBadge.style.borderColor = tpColor + '55';

    overlay.classList.add('visible');

    /* auto-hide after 5 s */
    _csEqOverlayTimer = setTimeout(() => {
      overlay.classList.remove('visible');
      _csEqOverlayTimer = null;
    }, 5000);
  }

  /* ── Cell Division ── */
