'use strict';
/* ════════════════════════════════════════════════════════════════════
   QualAnalysisSim — Качественный анализ (Стол-лаборатория v3)
   Layout: question-bar → 4 tubes + sample → reagent shelf → log → answer-bar
   ════════════════════════════════════════════════════════════════════ */

class QualAnalysisSim {
  /* ── Ion database ─────────────────────────────────────────────── */
  static IONS = [
    /* КАТИОНЫ */
    {
      id: 'Na+', label: 'Na⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Пламя жёлтое', color: '#FFD700', type: 'flame', positive: true },
        NaOH:     { obs: 'Нет заметного осадка', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'K+', label: 'K⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Пламя фиолетовое (через синее стекло)', color: '#CC00FF', type: 'flame', positive: true },
        NaOH:     { obs: 'Нет осадка', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'NH4+', label: 'NH₄⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'При нагреве — газ NH₃↑ (запах, влажная лакмусовая бумага синеет)', color: '#CCFFCC', type: 'gas', gasLabel: 'NH₃↑', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Mg2+', label: 'Mg²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Белый осадок Mg(OH)₂, не растворим в избытке NaOH', color: '#FFFFFF', type: 'precip', precipLabel: 'Mg(OH)₂↓', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Ca2+', label: 'Ca²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Пламя кирпично-красное', color: '#CC4400', type: 'flame', positive: true },
        NaOH:     { obs: 'Слабый белый осадок Ca(OH)₂ (малорастворим)', color: '#EEEEEE', type: 'precip', precipLabel: 'Ca(OH)₂↓', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Белый осадок CaSO₄↓ (малорастворим)', color: '#FFFFFF', type: 'precip', precipLabel: 'CaSO₄↓', positive: true },
      }
    },
    {
      id: 'Ba2+', label: 'Ba²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Пламя зелёное', color: '#00DD00', type: 'flame', positive: true },
        NaOH:     { obs: 'Белый осадок Ba(OH)₂', color: '#EEEEEE', type: 'precip', precipLabel: 'Ba(OH)₂↓', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Белый осадок BaSO₄↓, нерастворим в HNO₃', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true },
      }
    },
    {
      id: 'Al3+', label: 'Al³⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Белый осадок Al(OH)₃↓, растворяется в избытке NaOH (амфотерность)', color: '#DDDDDD', type: 'precip', precipLabel: 'Al(OH)₃↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Fe2+', label: 'Fe²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(100,160,80,0.25)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Зеленоватый осадок Fe(OH)₂↓', color: '#88BB66', type: 'precip', precipLabel: 'Fe(OH)₂↓', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Слабое розовое окрашивание (следы Fe³⁺), не яркое', color: 'rgba(255,100,80,0.3)', type: 'solution', positive: false },
        K3FeCN6:  { obs: 'Синий осадок — Турнбулева синь (Fe₃[Fe(CN)₆]₂)', color: '#1144CC', type: 'precip', precipLabel: 'Турн. синь↓', positive: true },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Fe3+', label: 'Fe³⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(200,100,30,0.3)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Бурый осадок Fe(OH)₃↓', color: '#884422', type: 'precip', precipLabel: 'Fe(OH)₃↓', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Ярко-красный раствор — роданид железа(III)', color: '#DD1100', type: 'solution', positive: true },
        K3FeCN6:  { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции (осаждается NaOH)', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Cu2+', label: 'Cu²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(30,120,200,0.35)',
      reactions: {
        flame:    { obs: 'Пламя зелёное (галогениды — синий)', color: '#00BB44', type: 'flame', positive: false },
        NaOH:     { obs: 'Голубой осадок Cu(OH)₂↓', color: '#5599FF', type: 'precip', precipLabel: 'Cu(OH)₂↓', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет характерной реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Ярко-синий раствор комплекса [Cu(NH₃)₄]²⁺', color: '#0044EE', type: 'solution', positive: true },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Ag+', label: 'Ag⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.1)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Коричневый осадок Ag₂O↓', color: '#886622', type: 'precip', precipLabel: 'Ag₂O↓', positive: false },
        HCl:      { obs: 'Белый творожистый осадок AgCl↓, темнеет на свету', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Белый осадок AgSCN↓', color: '#FFFFFF', type: 'precip', precipLabel: 'AgSCN↓', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Растворяется — комплекс [Ag(NH₃)₂]⁺', color: 'rgba(255,255,255,0.05)', type: 'solution', positive: true },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Pb2+', label: 'Pb²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(200,200,200,0.12)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Белый осадок Pb(OH)₂↓, растворим в избытке', color: '#EEEEEE', type: 'precip', precipLabel: 'Pb(OH)₂↓', positive: false },
        HCl:      { obs: 'Белый осадок PbCl₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbCl₂↓', positive: true },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Белый осадок PbSO₄↓', color: '#FFFFFF', type: 'precip', precipLabel: 'PbSO₄↓', positive: true },
      }
    },
    {
      id: 'Zn2+', label: 'Zn²⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке NaOH (амфотерность)', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: true, excess: 'Растворяется в избытке NaOH — амфотерность' },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Белый осадок Zn(OH)₂↓, растворяется в избытке — [Zn(NH₃)₄]²⁺', color: '#EEEEEE', type: 'precip', precipLabel: 'Zn(OH)₂↓', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'H+', label: 'H⁺', type: 'cat', group: 'Катионы',
      solColor: 'rgba(255,255,200,0.1)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нейтрализация. Лакмус синеет при добавлении щёлочи', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: true },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нейтрализация: H⁺ + NH₃ → NH₄⁺', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'OH-', label: 'OH⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нейтрализация, нет видимой реакции', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нейтрализация', color: 'rgba(255,255,255,0.08)', type: 'solution', positive: false },
      }
    },
    /* АНИОНЫ */
    {
      id: 'Cl-', label: 'Cl⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Белый творожистый осадок AgCl↓, нерастворим в HNO₃', color: '#DDDDDD', type: 'precip', precipLabel: 'AgCl↓', positive: true },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'Br-', label: 'Br⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(180,80,0,0.15)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Желтоватый осадок AgBr↓', color: '#EEEE88', type: 'precip', precipLabel: 'AgBr↓', positive: true },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'I-', label: 'I⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(100,0,120,0.2)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Жёлтый осадок AgI↓, практически нерастворим в NH₃', color: '#FFEE44', type: 'precip', precipLabel: 'AgI↓', positive: true },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'SO42-', label: 'SO₄²⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Белый осадок BaSO₄↓, нерастворим в кислотах', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₄↓', positive: true },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'SO32-', label: 'SO₃²⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Белый осадок BaSO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaSO₃↓', positive: true },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Газ SO₂↑ — запах жжёной серы', color: '#FFFFAA', type: 'gas', gasLabel: 'SO₂↑', positive: true },
      }
    },
    {
      id: 'CO32-', label: 'CO₃²⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Бурное выделение CO₂↑ (пузыри), мутит известковую воду Ca(OH)₂', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Белый осадок BaCO₃↓, растворяется в кислоте', color: '#FFFFFF', type: 'precip', precipLabel: 'BaCO₃↓', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Бурное выделение CO₂↑ (пузыри)', color: '#FFFFFF', type: 'gas', gasLabel: 'CO₂↑', positive: true },
      }
    },
    {
      id: 'NO3-', label: 'NO₃⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'При нагреве с Cu: бурый газ NO₂↑ («бурое кольцо» с FeSO₄)', color: '#DD8800', type: 'gas', gasLabel: 'NO₂↑', positive: true },
      }
    },
    {
      id: 'PO43-', label: 'PO₄³⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Жёлтый осадок Ag₃PO₄↓', color: '#EECC44', type: 'precip', precipLabel: 'Ag₃PO₄↓', positive: true },
        BaCl2:    { obs: 'Белый осадок Ba₃(PO₄)₂↓', color: '#FFFFFF', type: 'precip', precipLabel: 'Ba₃(PO₄)₂↓', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
      }
    },
    {
      id: 'S2-', label: 'S²⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(200,200,100,0.1)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true },
        AgNO3:    { obs: 'Чёрный осадок Ag₂S↓', color: '#111111', type: 'precip', precipLabel: 'Ag₂S↓', positive: true },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Газ H₂S↑ — запах тухлых яиц', color: '#FFFF88', type: 'gas', gasLabel: 'H₂S↑', positive: true },
      }
    },
    {
      id: 'CH3COO-', label: 'CH₃COO⁻', type: 'an', group: 'Анионы',
      solColor: 'rgba(255,255,255,0.08)',
      reactions: {
        flame:    { obs: 'Нет характерного окрашивания', color: null, type: 'none', positive: false },
        NaOH:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        HCl:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        AgNO3:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        BaCl2:    { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        KSCN:     { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        K3FeCN6:  { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        NH3:      { obs: 'Нет реакции', color: null, type: 'none', positive: false },
        H2SO4:    { obs: 'Запах уксуса; с FeCl₃ при нагреве — бурый осадок', color: '#AA6622', type: 'solution', positive: true },
      }
    },
  ];

  static REAGENTS = [
    { id: 'NaOH',    label: 'NaOH',          color: '#8866FF' },
    { id: 'HCl',     label: 'HCl',           color: '#FF6644' },
    { id: 'AgNO3',   label: 'AgNO₃',         color: '#CCCCCC' },
    { id: 'BaCl2',   label: 'BaCl₂',         color: '#44BBFF' },
    { id: 'KSCN',    label: 'KSCN',          color: '#FF4444' },
    { id: 'K3FeCN6', label: 'K₃[Fe(CN)₆]',  color: '#FFAA00' },
    { id: 'NH3',     label: 'NH₃',           color: '#AAFFAA' },
    { id: 'H2SO4',   label: 'H₂SO₄',        color: '#FFFF44' },
    { id: 'flame',   label: 'Пламя',         color: '#FF8800' },
  ];

  /* ── Tube count based on viewport ──────────────────────────────── */
  static _tubeCount() {
    return (typeof window !== 'undefined' && window.innerWidth < 600) ? 2 : 4;
  }

  /* ── constructor ─────────────────────────────────────────────── */
  constructor(container) {
    this._container = container;
    this._mode = 'free';           // 'free' | 'train' | 'exam'
    this._targetIon = null;        // ион для «Тренировки» (один)
    this._examIons = [];           // ионы для «Экзамена» (по одному на пробирку)
    this._log = [];                // все записи журнала
    this._answered = false;
    this._score = 0;
    this._scoreTotal = 0;
    this._raf = null;
    this._lastT = 0;
    this._dragReagent = null;
    this._activeTube = 0;          // индекс активной пробирки (0-3)
    this._helpVisible = false;
    this._tubeCount = QualAnalysisSim._tubeCount();

    /* per-tube state array (index 0..tubeCount-1) */
    this._tubes = [];
    this._tubeParticles = [];      // { drops, precip, gas } per tube
    this._tubeCanvas = null;
    this._tubeCtx = null;

    this._build();
    this._bindEvents();
    this._startMode('free');
  }

  /* ── Helpers ─────────────────────────────────────────────────── */
  _makeTubeState(solColor) {
    return {
      solColor: solColor || 'rgba(100,180,255,0.15)',
      precipColor: null,
      precipH: 0,
      gasLabel: null,
      flameColor: null,
      flameTimer: 0,
    };
  }

  _makeTubeParticles() {
    return { drops: [], precip: [], gas: [] };
  }

  _resetTubes() {
    this._tubes = [];
    this._tubeParticles = [];
    for (let i = 0; i < this._tubeCount; i++) {
      this._tubes.push(this._makeTubeState());
      this._tubeParticles.push(this._makeTubeParticles());
    }
    /* sample tube (index = tubeCount) — always present in train/exam */
    this._tubes.push(this._makeTubeState());
    this._tubeParticles.push(this._makeTubeParticles());
  }

  /* ── DOM build ───────────────────────────────────────────────── */
  _build() {
    this._container.innerHTML = '';
    this._container.style.cssText = [
      'display:flex',
      'flex-direction:column',
      'height:100%',
      'background:#0D0D1A',
      'color:#E0E0FF',
      'font-family:Manrope,sans-serif',
      'overflow:hidden',
      'user-select:none',
      'position:relative',
    ].join(';');

    /* ── QUESTION BAR ── */
    const qbar = document.createElement('div');
    qbar.id = 'qa-qbar';
    qbar.style.cssText = [
      'flex-shrink:0',
      'padding:10px 16px 8px',
      'background:rgba(155,93,229,0.10)',
      'border-bottom:1px solid rgba(155,93,229,0.22)',
      'position:relative',
    ].join(';');

    /* task text */
    const taskText = document.createElement('div');
    taskText.id = 'qa-task';
    taskText.style.cssText = [
      'font-size:1.05rem',
      'font-weight:700',
      'color:rgba(255,255,255,0.95)',
      'margin-bottom:8px',
      'line-height:1.4',
      'padding-right:44px',
    ].join(';');
    taskText.textContent = 'Выбери режим и начни эксперимент';
    qbar.appendChild(taskText);

    /* controls row */
    const ctrlRow = document.createElement('div');
    ctrlRow.style.cssText = 'display:flex;align-items:center;gap:12px;flex-wrap:wrap';

    /* mode select */
    const modeSel = document.createElement('select');
    modeSel.id = 'qa-mode-sel';
    modeSel.style.cssText = [
      'padding:5px 10px',
      'border-radius:8px',
      'border:1px solid rgba(155,93,229,0.45)',
      'background:#1a1a2e',
      'color:#D0A0FF',
      'font-size:.92rem',
      'font-weight:700',
      'cursor:pointer',
      'outline:none',
    ].join(';');
    [['free','Свободно'],['train','Тренировка'],['exam','Экзамен']].forEach(([v,t]) => {
      const o = document.createElement('option');
      o.value = v; o.textContent = t;
      modeSel.appendChild(o);
    });
    ctrlRow.appendChild(modeSel);

    /* score */
    const scoreEl = document.createElement('span');
    scoreEl.id = 'qa-score-wrap';
    scoreEl.style.cssText = 'font-size:.95rem;color:rgba(255,255,255,0.65)';
    scoreEl.innerHTML = 'Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span><span id="qa-score-total" style="color:rgba(255,255,255,0.45)"></span>';
    ctrlRow.appendChild(scoreEl);

    /* new task button */
    const btnNew = document.createElement('button');
    btnNew.id = 'qa-btn-new';
    btnNew.textContent = 'Новая задача';
    btnNew.style.cssText = [
      'padding:5px 14px',
      'border-radius:8px',
      'border:1px solid rgba(6,214,224,0.5)',
      'background:rgba(6,214,224,0.1)',
      'color:#06D6E0',
      'font-size:.92rem',
      'font-weight:700',
      'cursor:pointer',
    ].join(';');
    ctrlRow.appendChild(btnNew);

    qbar.appendChild(ctrlRow);

    /* help button */
    const btnHelp = document.createElement('button');
    btnHelp.id = 'qa-btn-help';
    btnHelp.title = 'Справка';
    btnHelp.style.cssText = [
      'position:absolute',
      'top:10px',
      'right:14px',
      'width:30px',
      'height:30px',
      'border-radius:50%',
      'border:1px solid rgba(255,255,255,0.25)',
      'background:rgba(255,255,255,0.07)',
      'color:rgba(255,255,255,0.75)',
      'font-size:.95rem',
      'font-weight:700',
      'cursor:pointer',
      'display:flex',
      'align-items:center',
      'justify-content:center',
    ].join(';');
    btnHelp.textContent = '?';
    qbar.appendChild(btnHelp);

    this._container.appendChild(qbar);

    /* ── HELP POPOVER ── */
    const helpPop = document.createElement('div');
    helpPop.id = 'qa-help-pop';
    helpPop.style.cssText = [
      'display:none',
      'position:absolute',
      'top:90px',
      'right:14px',
      'z-index:100',
      'width:280px',
      'background:#1a1a2e',
      'border:1px solid rgba(155,93,229,0.45)',
      'border-radius:12px',
      'padding:14px 16px',
      'font-size:.88rem',
      'color:rgba(255,255,255,0.85)',
      'line-height:1.55',
      'box-shadow:0 8px 32px rgba(0,0,0,0.6)',
    ].join(';');
    helpPop.innerHTML = [
      '<b style="font-size:.95rem;color:#D0A0FF">Как пользоваться</b>',
      '<ul style="margin:8px 0 0;padding-left:18px;display:flex;flex-direction:column;gap:5px">',
        '<li>Перетащи реагент в пробирку или нажми реагент (активная пробирка выделена синим)</li>',
        '<li>Наблюдай реакцию на холсте и читай журнал</li>',
        '<li><b>Свободно</b> — просто эксперименты, нет задания</li>',
        '<li><b>Тренировка</b> — один неизвестный ион в Образце, определи его</li>',
        '<li><b>Экзамен</b> — 4 неизвестных иона (по одному в каждой пробирке), без подсказок</li>',
        '<li>Используй пробирки Проб1-4 как вспомогательные для сравнения</li>',
      '</ul>',
    ].join('');
    this._container.appendChild(helpPop);

    /* ── SCENE (canvas) ── */
    const scene = document.createElement('div');
    scene.id = 'qa-scene';
    scene.style.cssText = [
      'flex:1',
      'min-height:220px',
      'position:relative',
      'overflow:hidden',
    ].join(';');

    const canvas = document.createElement('canvas');
    canvas.id = 'qa-canvas';
    canvas.style.cssText = 'display:block;width:100%;height:100%';
    scene.appendChild(canvas);
    this._container.appendChild(scene);
    this._tubeCanvas = canvas;
    this._tubeCtx = canvas.getContext('2d');

    /* ── REAGENT SHELF ── */
    const shelf = document.createElement('div');
    shelf.id = 'qa-shelf';
    shelf.style.cssText = [
      'flex-shrink:0',
      'padding:8px 14px',
      'border-top:1px solid rgba(255,255,255,0.07)',
      'border-bottom:1px solid rgba(255,255,255,0.07)',
      'background:rgba(255,255,255,0.025)',
      'display:flex',
      'flex-wrap:wrap',
      'gap:6px',
      'align-items:center',
      'min-height:52px',
    ].join(';');

    const shelfLabel = document.createElement('span');
    shelfLabel.style.cssText = [
      'font-size:.78rem',
      'font-weight:700',
      'text-transform:uppercase',
      'letter-spacing:.06em',
      'color:rgba(255,255,255,0.5)',
      'flex-shrink:0',
    ].join(';');
    shelfLabel.textContent = 'Реагенты (drag в пробирку):';
    shelf.appendChild(shelfLabel);

    QualAnalysisSim.REAGENTS.forEach(r => {
      const btn = document.createElement('button');
      btn.className = 'qa-reagent-btn';
      btn.dataset.reagent = r.id;
      btn.draggable = true;
      btn.style.cssText = [
        'padding:6px 13px',
        'border-radius:8px',
        `border:1px solid ${r.color}55`,
        `background:${r.color}15`,
        `color:${r.color}`,
        'font-size:.9rem',
        'font-weight:700',
        'cursor:grab',
        'transition:background .15s,transform .1s',
      ].join(';');
      btn.textContent = r.label;
      btn.addEventListener('mouseenter', () => {
        btn.style.background = r.color + '30';
        btn.style.transform = 'translateY(-2px)';
      });
      btn.addEventListener('mouseleave', () => {
        btn.style.background = r.color + '15';
        btn.style.transform = '';
      });
      shelf.appendChild(btn);
    });
    this._container.appendChild(shelf);

    /* ── LOG PANEL ── */
    const logWrap = document.createElement('div');
    logWrap.style.cssText = [
      'flex-shrink:0',
      'max-height:180px',
      'overflow-y:auto',
      'border-bottom:1px solid rgba(255,255,255,0.07)',
      'background:rgba(0,0,0,0.15)',
    ].join(';');

    const logHeader = document.createElement('div');
    logHeader.style.cssText = [
      'display:flex',
      'align-items:center',
      'justify-content:space-between',
      'padding:8px 14px 5px',
      'font-size:.78rem',
      'font-weight:700',
      'text-transform:uppercase',
      'letter-spacing:.07em',
      'color:rgba(255,255,255,0.55)',
      'border-bottom:1px solid rgba(255,255,255,0.06)',
      'position:sticky',
      'top:0',
      'background:#0D0D1A',
      'z-index:2',
    ].join(';');

    const logTitle = document.createElement('span');
    logTitle.textContent = 'Журнал наблюдений';
    logHeader.appendChild(logTitle);

    const btnClearLog = document.createElement('button');
    btnClearLog.id = 'qa-btn-clear-log';
    btnClearLog.textContent = 'Очистить';
    btnClearLog.style.cssText = [
      'padding:3px 10px',
      'border-radius:6px',
      'border:1px solid rgba(255,255,255,0.18)',
      'background:rgba(255,255,255,0.04)',
      'color:rgba(255,255,255,0.65)',
      'font-size:.8rem',
      'cursor:pointer',
    ].join(';');
    logHeader.appendChild(btnClearLog);
    logWrap.appendChild(logHeader);

    const logList = document.createElement('div');
    logList.id = 'qa-log';
    logList.style.cssText = 'padding:8px 12px;display:flex;flex-direction:column;gap:5px';
    logWrap.appendChild(logList);

    const logHint = document.createElement('div');
    logHint.id = 'qa-log-hint';
    logHint.style.cssText = [
      'padding:10px 14px',
      'font-size:.88rem',
      'color:rgba(255,255,255,0.38)',
      'text-align:center',
    ].join(';');
    logHint.textContent = 'Перетащи реагент в пробирку, чтобы начать';
    logWrap.appendChild(logHint);
    this._container.appendChild(logWrap);

    /* ── ANSWER BAR ── */
    const ansBar = document.createElement('div');
    ansBar.id = 'qa-ansbar';
    ansBar.style.cssText = [
      'flex-shrink:0',
      'display:flex',
      'align-items:center',
      'gap:10px',
      'flex-wrap:wrap',
      'padding:8px 14px',
      'border-top:1px solid rgba(255,255,255,0.07)',
      'background:rgba(255,255,255,0.02)',
      'min-height:50px',
    ].join(';');

    const ansLabel = document.createElement('span');
    ansLabel.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.65);flex-shrink:0';
    ansLabel.textContent = 'ОТВЕТ:';
    ansBar.appendChild(ansLabel);

    const ansSel = document.createElement('select');
    ansSel.id = 'qa-answer-sel';
    ansSel.style.cssText = [
      'padding:6px 11px',
      'border-radius:8px',
      'border:1px solid rgba(255,255,255,0.2)',
      'background:#1a1a2e',
      'color:#E0E0FF',
      'font-size:.9rem',
      'cursor:pointer',
      'outline:none',
      'flex:1',
      'min-width:140px',
      'max-width:240px',
    ].join(';');
    ansBar.appendChild(ansSel);

    const btnSubmit = document.createElement('button');
    btnSubmit.id = 'qa-submit';
    btnSubmit.textContent = 'Ответить';
    btnSubmit.style.cssText = [
      'padding:6px 18px',
      'border-radius:8px',
      'border:none',
      'background:linear-gradient(135deg,#9B5DE5,#06D6E0)',
      'color:#fff',
      'font-size:.92rem',
      'font-weight:700',
      'cursor:pointer',
    ].join(';');
    ansBar.appendChild(btnSubmit);

    const verdict = document.createElement('div');
    verdict.id = 'qa-verdict';
    verdict.style.cssText = [
      'display:none',
      'font-size:.92rem',
      'font-weight:700',
      'padding:5px 13px',
      'border-radius:8px',
    ].join(';');
    ansBar.appendChild(verdict);

    this._container.appendChild(ansBar);

    this._resizeFit();
    this._updateAnswerSelect();
  }

  /* ── Resize ──────────────────────────────────────────────────── */
  _resizeFit() {
    const c = this._tubeCanvas;
    if (!c) return;
    const p = c.parentElement;
    if (!p) return;
    const rect = p.getBoundingClientRect();
    const w = rect.width  || p.clientWidth  || 500;
    const h = rect.height || p.clientHeight || 300;
    c.width  = Math.round(w);
    c.height = Math.round(h);
    this._W = c.width;
    this._H = c.height;
    /* recheck tube count on resize */
    const newCount = QualAnalysisSim._tubeCount();
    if (newCount !== this._tubeCount) {
      this._tubeCount = newCount;
      this._resetTubes();
    }
    this._drawScene();
  }

  /* ── Tube geometry ───────────────────────────────────────────── */
  _tubeGeometry() {
    const W = this._W || 500;
    const H = this._H || 300;
    const n = this._tubeCount;
    /* show sample only in train/exam */
    const showSample = this._mode !== 'free';
    const totalSlots = showSample ? n + 1 : n;
    const tubeW  = Math.max(50, Math.min(80, (W - 32) / totalSlots - 18));
    const tH     = Math.min(H * 0.72, 150);
    const tTop   = Math.max(20, (H - tH) * 0.38);
    const tBot   = tTop + tH;
    const totalW = totalSlots * (tubeW + 18) - 18;
    const startX = (W - totalW) / 2;

    const tubes = [];
    for (let i = 0; i < totalSlots; i++) {
      const isSample = showSample && i === n;
      const tx = startX + i * (tubeW + 18);
      tubes.push({ i, tx, tubeW, tTop, tBot, isSample });
    }
    return { tubes, tubeW, tTop, tBot, H, W };
  }

  /* ── Hit test — which tube slot contains point (x,y) ── */
  _hitTestTube(x, y) {
    const { tubes } = this._tubeGeometry();
    for (const t of tubes) {
      if (x >= t.tx - 8 && x <= t.tx + t.tubeW + 8 &&
          y >= t.tTop - 10 && y <= t.tBot + 28) {
        return t.i;
      }
    }
    return -1;
  }

  /* ── Events ──────────────────────────────────────────────────── */
  _bindEvents() {
    const $ = id => document.getElementById(id);

    /* mode selector */
    $('qa-mode-sel').addEventListener('change', e => {
      this._startMode(e.target.value);
    });

    /* new task */
    $('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));

    /* submit */
    $('qa-submit').addEventListener('click', () => this._submitAnswer());

    /* clear log */
    $('qa-btn-clear-log').addEventListener('click', () => this._clearLog());

    /* help toggle */
    $('qa-btn-help').addEventListener('click', () => {
      this._helpVisible = !this._helpVisible;
      $('qa-help-pop').style.display = this._helpVisible ? 'block' : 'none';
    });
    document.addEventListener('click', e => {
      if (this._helpVisible && !e.target.closest('#qa-help-pop') && e.target.id !== 'qa-btn-help') {
        this._helpVisible = false;
        const pop = $('qa-help-pop');
        if (pop) pop.style.display = 'none';
      }
    }, true);

    /* reagent buttons — click on active tube */
    this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const rId = btn.dataset.reagent;
        this._applyReagent(this._activeTube, rId);
      });

      /* drag start */
      btn.addEventListener('dragstart', e => {
        this._dragReagent = btn.dataset.reagent;
        e.dataTransfer.effectAllowed = 'copy';
        e.dataTransfer.setData('text/plain', btn.dataset.reagent);
      });
    });

    /* canvas — drop target + click to select tube */
    const canvas = this._tubeCanvas;

    canvas.addEventListener('dragover', e => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
    });

    canvas.addEventListener('drop', e => {
      e.preventDefault();
      const rId = this._dragReagent || e.dataTransfer.getData('text/plain');
      if (!rId) return;
      const rect = canvas.getBoundingClientRect();
      const x = (e.clientX - rect.left) * (canvas.width / rect.width);
      const y = (e.clientY - rect.top)  * (canvas.height / rect.height);
      const idx = this._hitTestTube(x, y);
      if (idx >= 0) {
        this._activeTube = idx;
        this._applyReagent(idx, rId);
      }
      this._dragReagent = null;
    });

    canvas.addEventListener('click', e => {
      const rect = canvas.getBoundingClientRect();
      const x = (e.clientX - rect.left) * (canvas.width / rect.width);
      const y = (e.clientY - rect.top)  * (canvas.height / rect.height);
      const idx = this._hitTestTube(x, y);
      if (idx >= 0) {
        this._activeTube = idx;
        /* in exam: switching tube allows re-submit; verdict resets */
        if (this._mode === 'exam') {
          const alreadyAnswered = this._examAnswered && this._examAnswered[idx];
          this._answered = !!alreadyAnswered;
          const verdict = document.getElementById('qa-verdict');
          if (verdict) {
            if (alreadyAnswered) {
              verdict.style.display = 'block';
              verdict.textContent = alreadyAnswered.text;
              verdict.style.background = alreadyAnswered.bg;
              verdict.style.color = alreadyAnswered.fg;
              verdict.style.border = alreadyAnswered.border;
            } else {
              verdict.style.display = 'none';
              verdict.textContent = '';
            }
          }
        }
        this._updateTaskText();
        this._drawScene();
      }
    });

    /* touch fallback: tap reagent then tap tube */
    canvas.addEventListener('touchstart', e => {
      if (!this._pendingReagent) return;
      const t = e.touches[0];
      const rect = canvas.getBoundingClientRect();
      const x = (t.clientX - rect.left) * (canvas.width / rect.width);
      const y = (t.clientY - rect.top)  * (canvas.height / rect.height);
      const idx = this._hitTestTube(x, y);
      if (idx >= 0) {
        this._applyReagent(idx, this._pendingReagent);
        this._pendingReagent = null;
        e.preventDefault();
      }
    }, { passive: false });

    /* resize */
    if (window.ResizeObserver) {
      const ro = new ResizeObserver(() => this._resizeFit());
      ro.observe(this._tubeCanvas.parentElement || this._container);
    }
  }

  /* ── Mode start ──────────────────────────────────────────────── */
  _startMode(mode) {
    this._mode = mode;
    this._answered = false;
    this._examAnswered = {};      // per-tube answered tracking for exam
    this._log = [];
    this._dragReagent = null;
    this._pendingReagent = null;
    this._tubeCount = QualAnalysisSim._tubeCount();
    this._activeTube = 0;

    /* update mode selector UI */
    const modeSel = document.getElementById('qa-mode-sel');
    if (modeSel) modeSel.value = mode;

    const ions = QualAnalysisSim.IONS;

    /* pick known ions for helper tubes (visible labels) */
    this._helperIons = [];
    const shuffled = ions.slice().sort(() => Math.random() - 0.5);
    for (let i = 0; i < this._tubeCount; i++) {
      this._helperIons.push(shuffled[i % shuffled.length]);
    }

    /* pick random ions per mode */
    if (mode === 'train') {
      /* pick target distinct from helpers if possible */
      const helperIds = new Set(this._helperIons.map(h => h.id));
      const pool = ions.filter(i => !helperIds.has(i.id));
      const src = pool.length > 0 ? pool : ions;
      this._targetIon = src[Math.floor(Math.random() * src.length)];
      this._examIons = [];
    } else if (mode === 'exam') {
      /* exam: helper tubes hold unknown ions, no sample */
      this._examIons = this._helperIons.slice();
      this._helperIons = [];       // hide helper labels in exam (they're unknown)
      this._targetIon = null;
    } else {
      /* free mode: helpers have known ions, no sample */
      this._targetIon = null;
      this._examIons = [];
    }

    /* reset tubes */
    this._resetTubes();
    /* paint helper tubes with their ion colors (free + train modes) */
    if (mode !== 'exam') {
      for (let i = 0; i < this._tubeCount; i++) {
        if (this._helperIons[i]) {
          this._tubes[i].solColor = this._helperIons[i].solColor || 'rgba(100,180,255,0.15)';
        }
      }
    }
    /* set solution color for sample tube in train mode */
    if (mode === 'train' && this._targetIon) {
      this._tubes[this._tubeCount].solColor = this._targetIon.solColor || 'rgba(100,180,255,0.15)';
    }
    /* set colors for exam tubes */
    if (mode === 'exam') {
      for (let i = 0; i < this._tubeCount; i++) {
        if (this._examIons[i]) {
          this._tubes[i].solColor = this._examIons[i].solColor || 'rgba(100,180,255,0.15)';
        }
      }
    }

    /* reset log */
    const logEl = document.getElementById('qa-log');
    if (logEl) logEl.innerHTML = '';
    const hint = document.getElementById('qa-log-hint');
    if (hint) hint.style.display = '';

    /* verdict */
    const verdict = document.getElementById('qa-verdict');
    if (verdict) { verdict.style.display = 'none'; verdict.textContent = ''; }

    /* update answer select */
    this._updateAnswerSelect();

    /* task text */
    this._updateTaskText();

    /* answer bar visibility */
    const ansBar = document.getElementById('qa-ansbar');
    if (ansBar) ansBar.style.display = (mode === 'free') ? 'none' : 'flex';

    this._drawScene();
    if (!this._raf) this._animLoop(performance.now());
  }

  _updateTaskText() {
    const el = document.getElementById('qa-task');
    if (!el) return;
    if (this._mode === 'free') {
      el.textContent = 'Свободно: в каждой пробирке известный ион (см. подпись) — пробуй реагенты, изучай реакции';
    } else if (this._mode === 'train') {
      el.textContent = 'Тренировка: определи ион в Образце (золотая рамка). В Проб1–4 — известные ионы для сравнения';
    } else {
      const ansFor = 'Проб' + (this._activeTube + 1);
      el.textContent = 'Экзамен: в каждой пробирке свой неизвестный ион. Кликни на пробирку → определи реакциями → ответь. Сейчас отвечаешь для: ' + ansFor;
    }
  }

  _updateAnswerSelect() {
    const sel = document.getElementById('qa-answer-sel');
    if (!sel) return;
    sel.innerHTML = '<option value="">— выберите ион —</option>';
    ['Катионы', 'Анионы'].forEach(grp => {
      const og = document.createElement('optgroup');
      og.label = grp;
      QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => {
        const opt = document.createElement('option');
        opt.value = ion.id;
        opt.textContent = ion.label;
        og.appendChild(opt);
      });
      sel.appendChild(og);
    });
  }

  /* ── Apply reagent to tube ───────────────────────────────────── */
  _applyReagent(tubeIdx, reagentId) {
    /* which ion is in this tube? */
    const isSample = tubeIdx === this._tubeCount;
    let ion = this._getIonForTube(tubeIdx);
    /* if no assigned ion → free tube with blank reactions */
    /* still animate the drop but log "нет ионов" */
    if (!ion) {
      /* animate drop only */
      const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
      this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF');
      if (window.LabFX) LabFX.sound.play('pour');
      return;
    }

    const rxn = ion.reactions[reagentId];
    if (!rxn) return;

    const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
    const rLabel = rInfo ? rInfo.label : reagentId;
    const ts = this._tubes[tubeIdx];
    const tp = this._tubeParticles[tubeIdx];

    if (window.LabFX) LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour');

    /* update tube visual state */
    if (rxn.type === 'flame') {
      ts.flameColor = rxn.color;
      ts.flameTimer = 2.0;
    } else if (rxn.type === 'precip' && rxn.color) {
      ts.precipColor = rxn.color;
      ts.precipH = 0;
      tp.precip = this._spawnPrecipParticles(tubeIdx, rxn.color);
    } else if (rxn.type === 'solution' && rxn.color) {
      ts.solColor = rxn.color;
    } else if (rxn.type === 'gas') {
      ts.gasLabel = rxn.gasLabel || '↑';
      tp.gas = this._spawnGasParticles(tubeIdx, rxn.color || '#FFFFFF');
    }

    this._spawnDrop(tubeIdx, rInfo ? rInfo.color : '#FFF');

    /* determine tube name for log */
    const isSampleTube = tubeIdx === this._tubeCount;
    const tubeName = isSampleTube ? 'Образец' : ('Проб' + (tubeIdx + 1));

    const entry = {
      tubeIdx,
      tubeName,
      reagentId,
      reagentLabel: rLabel,
      reagentColor: rInfo ? rInfo.color : '#AAA',
      obs: rxn.obs,
      positive: rxn.positive,
      excess: rxn.excess || null,
    };
    this._log.push(entry);
    this._renderLogEntry(entry);
  }

  /* ── Particle spawners ───────────────────────────────────────── */
  _tubeCenter(tubeIdx) {
    const { tubes } = this._tubeGeometry();
    const t = tubes[tubeIdx];
    if (!t) return { x: this._W / 2, tTop: 20, tBot: 200, tubeW: 70 };
    return { x: t.tx + t.tubeW / 2, tTop: t.tTop, tBot: t.tBot, tubeW: t.tubeW };
  }

  _spawnDrop(tubeIdx, color) {
    const { x, tTop } = this._tubeCenter(tubeIdx);
    const tp = this._tubeParticles[tubeIdx];
    if (!tp) return;
    tp.drops.push({ x, y: tTop - 30, vy: 3, color, alpha: 1, done: false });
  }

  _spawnPrecipParticles(tubeIdx, color) {
    const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx);
    const cy = tTop + (tBot - tTop) * 0.5;
    const ps = [];
    for (let i = 0; i < 28; i++) {
      ps.push({
        x: x + (Math.random() - 0.5) * tubeW * 0.7,
        y: cy,
        vy: 0.8 + Math.random() * 1.8,
        vx: (Math.random() - 0.5) * 1.5,
        color,
        r: 2.5 + Math.random() * 3,
        done: false,
      });
    }
    return ps;
  }

  _spawnGasParticles(tubeIdx, color) {
    const { x, tTop, tBot, tubeW } = this._tubeCenter(tubeIdx);
    const cy = tTop + (tBot - tTop) * 0.4;
    const ps = [];
    for (let i = 0; i < 22; i++) {
      ps.push({
        x: x + (Math.random() - 0.5) * tubeW * 0.5,
        y: cy,
        vy: -(0.8 + Math.random() * 1.6),
        vx: (Math.random() - 0.5) * 1,
        color,
        r: 3.5 + Math.random() * 4,
        alpha: 0.85,
        done: false,
      });
    }
    return ps;
  }

  /* ── Log rendering ───────────────────────────────────────────── */
  _clearLog() {
    this._log = [];
    const logEl = document.getElementById('qa-log');
    if (logEl) logEl.innerHTML = '';
    const hint = document.getElementById('qa-log-hint');
    if (hint) hint.style.display = '';
  }

  _renderLogEntry(entry) {
    const hint = document.getElementById('qa-log-hint');
    if (hint) hint.style.display = 'none';

    const logEl = document.getElementById('qa-log');
    if (!logEl) return;

    const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.35)';

    const card = document.createElement('div');
    card.style.cssText = [
      'padding:7px 11px',
      'border-radius:8px',
      `border-left:3px solid ${entry.reagentColor}`,
      'background:rgba(255,255,255,0.04)',
      'border-top:1px solid rgba(255,255,255,0.06)',
      'border-right:1px solid rgba(255,255,255,0.06)',
      'border-bottom:1px solid rgba(255,255,255,0.06)',
      'display:flex',
      'gap:8px',
      'align-items:flex-start',
    ].join(';');

    /* left part */
    const left = document.createElement('div');
    left.style.cssText = 'flex:1;min-width:0';

    const topLine = document.createElement('div');
    topLine.style.cssText = 'display:flex;align-items:center;gap:5px;flex-wrap:wrap;margin-bottom:3px';

    const tubeSpan = document.createElement('span');
    tubeSpan.style.cssText = 'font-size:.82rem;font-weight:700;color:rgba(255,255,255,0.6)';
    tubeSpan.textContent = entry.tubeName;

    const plusSpan = document.createElement('span');
    plusSpan.style.cssText = 'font-size:.82rem;color:rgba(255,255,255,0.35)';
    plusSpan.textContent = '+';

    const rSpan = document.createElement('span');
    rSpan.style.cssText = `font-size:.9rem;font-weight:700;color:${entry.reagentColor}`;
    rSpan.textContent = entry.reagentLabel;

    const arrSpan = document.createElement('span');
    arrSpan.style.cssText = 'font-size:.85rem;color:rgba(255,255,255,0.35)';
    arrSpan.textContent = '→';

    topLine.appendChild(tubeSpan);
    topLine.appendChild(plusSpan);
    topLine.appendChild(rSpan);
    topLine.appendChild(arrSpan);
    left.appendChild(topLine);

    const obsDiv = document.createElement('div');
    obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.85);line-height:1.4';
    obsDiv.textContent = entry.obs;
    left.appendChild(obsDiv);

    if (entry.excess) {
      const exDiv = document.createElement('div');
      exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;margin-top:2px';
      exDiv.textContent = entry.excess;
      left.appendChild(exDiv);
    }

    card.appendChild(left);

    /* badge */
    if (entry.positive) {
      const badge = document.createElement('span');
      badge.style.cssText = [
        'flex-shrink:0',
        'font-size:.75rem',
        'font-weight:700',
        'padding:2px 7px',
        'border-radius:10px',
        'background:rgba(94,240,142,0.18)',
        'color:#5EF08E',
        'border:1px solid rgba(94,240,142,0.35)',
        'margin-top:1px',
      ].join(';');
      badge.textContent = 'pos';
      card.appendChild(badge);
    }

    logEl.appendChild(card);
    /* scroll to bottom */
    logEl.parentElement.scrollTop = logEl.parentElement.scrollHeight;
  }

  /* ── Submit answer ───────────────────────────────────────────── */
  _submitAnswer() {
    if (this._answered) return;
    if (this._mode === 'free') return;

    const sel = document.getElementById('qa-answer-sel');
    const chosen = sel ? sel.value : '';
    if (!chosen) return;

    this._answered = true;
    this._scoreTotal++;

    let correct = false;
    let correctIon = null;

    if (this._mode === 'train') {
      correctIon = this._targetIon;
      correct = chosen === correctIon.id;
    } else if (this._mode === 'exam') {
      /* answer applies to active tube */
      correctIon = this._examIons[this._activeTube] || null;
      correct = correctIon && chosen === correctIon.id;
    }

    if (correct) this._score++;

    const verdict = document.getElementById('qa-verdict');
    if (!verdict) return;
    verdict.style.display = 'block';

    const scoreEl = document.getElementById('qa-score');
    if (scoreEl) scoreEl.textContent = this._score;
    const totalEl = document.getElementById('qa-score-total');
    if (totalEl) totalEl.textContent = '/' + this._scoreTotal;

    let vText, vBg, vFg, vBorder;
    if (correct) {
      vText = 'Верно! Это ' + (correctIon ? correctIon.label : chosen);
      vBg = 'rgba(94,240,142,0.15)';
      vFg = '#5EF08E';
      vBorder = '1px solid rgba(94,240,142,0.35)';
      if (window.LabFX) LabFX.sound.play('chime');
    } else {
      const label = correctIon ? correctIon.label : '?';
      vText = 'Неверно — это ' + label;
      vBg = 'rgba(239,71,111,0.12)';
      vFg = '#EF476F';
      vBorder = '1px solid rgba(239,71,111,0.35)';
    }
    verdict.textContent = vText;
    verdict.style.background = vBg;
    verdict.style.color = vFg;
    verdict.style.border = vBorder;
    verdict.style.display = 'block';

    /* exam: remember verdict per tube; allow switching to next */
    if (this._mode === 'exam') {
      this._examAnswered = this._examAnswered || {};
      this._examAnswered[this._activeTube] = { text: vText, bg: vBg, fg: vFg, border: vBorder, correct };
    }
  }

  /* ── Animation loop ──────────────────────────────────────────── */
  _animLoop(t) {
    this._raf = requestAnimationFrame(ts => this._animLoop(ts));
    const dt = Math.min((t - this._lastT) / 1000, 0.05);
    this._lastT = t;

    let needDraw = false;

    /* update particles per tube */
    for (let i = 0; i < this._tubes.length; i++) {
      const ts2 = this._tubes[i];
      const tp = this._tubeParticles[i];
      if (!tp) continue;

      /* flame timer */
      if (ts2.flameTimer > 0) {
        ts2.flameTimer -= dt;
        if (ts2.flameTimer <= 0) { ts2.flameColor = null; ts2.flameTimer = 0; }
        needDraw = true;
      }

      /* drops */
      const { tBot } = this._tubeCenter(i);
      const floor = tBot * 0.92;
      tp.drops = tp.drops.filter(d => {
        if (d.done) return false;
        d.y += d.vy;
        d.vy += 0.3;
        if (d.y > floor) { d.done = true; needDraw = true; return false; }
        needDraw = true;
        return true;
      });

      /* precip */
      const precipFloor = tBot - 4;
      tp.precip.forEach(p => {
        if (!p.done) {
          p.x += p.vx;
          p.y += p.vy;
          p.vy *= 0.97;
          if (p.y >= precipFloor) {
            p.y = precipFloor;
            p.vy = 0; p.vx = 0;
            p.done = true;
            ts2.precipH = Math.min(ts2.precipH + 2, 32);
          }
          needDraw = true;
        }
      });

      /* gas */
      tp.gas = tp.gas.filter(p => {
        if (p.done) return false;
        p.y += p.vy;
        p.x += p.vx;
        p.alpha -= dt * 0.55;
        if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; }
        needDraw = true;
        return true;
      });
    }

    if (needDraw) this._drawScene();
  }

  /* ── Draw ────────────────────────────────────────────────────── */
  _drawScene() {
    const ctx = this._tubeCtx;
    const W = this._W || 500;
    const H = this._H || 300;
    if (!ctx) return;

    ctx.clearRect(0, 0, W, H);

    /* dark background */
    ctx.fillStyle = '#0D0D1A';
    ctx.fillRect(0, 0, W, H);

    /* bench surface */
    ctx.fillStyle = 'rgba(255,255,255,0.018)';
    ctx.fillRect(0, H * 0.84, W, H * 0.16);
    ctx.strokeStyle = 'rgba(255,255,255,0.07)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, H * 0.84);
    ctx.lineTo(W, H * 0.84);
    ctx.stroke();

    const geom = this._tubeGeometry();
    const showSample = this._mode !== 'free';

    for (const t of geom.tubes) {
      this._drawOneTube(ctx, t, showSample);
    }

    /* clear-all button hint */
    if (showSample) {
      const sampleT = geom.tubes[this._tubeCount];
      if (sampleT) {
        const bx = sampleT.tx + sampleT.tubeW / 2;
        const by = sampleT.tBot + 46;
        ctx.save();
        ctx.font = '700 11px Manrope,sans-serif';
        ctx.fillStyle = 'rgba(255,255,255,0.3)';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('[Очистить все]', bx, by);
        ctx.restore();
        this._clearBtnRect = { x: bx - 46, y: by - 9, w: 92, h: 18 };
      }
    }
  }

  _drawOneTube(ctx, t, showSample) {
    const { i, tx, tubeW, tTop, tBot, isSample } = t;
    const H = this._H || 300;
    const tH = tBot - tTop;
    const rBot = tubeW * 0.5;
    const ts2 = this._tubes[i];
    const tp = this._tubeParticles[i];
    if (!ts2 || !tp) return;

    const isActive = i === this._activeTube;

    /* active highlight glow */
    if (isActive) {
      ctx.save();
      ctx.shadowColor = '#4CC9F0';
      ctx.shadowBlur = 18;
      ctx.strokeStyle = '#4CC9F088';
      ctx.lineWidth = 2;
      ctx.strokeRect(tx - 5, tTop - 5, tubeW + 10, tH + 10 + rBot);
      ctx.restore();
    }

    /* sample highlight (gold) */
    if (isSample) {
      ctx.save();
      ctx.shadowColor = '#FFD166';
      ctx.shadowBlur = 20;
      ctx.strokeStyle = '#FFD16688';
      ctx.lineWidth = 2;
      ctx.strokeRect(tx - 7, tTop - 7, tubeW + 14, tH + 14 + rBot);
      ctx.restore();
    }

    /* flame halo */
    if (ts2.flameColor) {
      const fc = ts2.flameColor;
      const gx = tx + tubeW / 2;
      const gy = tTop - 28;
      const gr = ctx.createRadialGradient(gx, gy, 5, gx, gy, 80);
      gr.addColorStop(0, fc + 'CC');
      gr.addColorStop(0.4, fc + '44');
      gr.addColorStop(1, fc + '00');
      ctx.fillStyle = gr;
      ctx.beginPath();
      ctx.arc(gx, gy, 80, 0, Math.PI * 2);
      ctx.fill();

      ctx.save();
      ctx.font = 'bold 11px Manrope,sans-serif';
      ctx.fillStyle = fc;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      const flameLabel = this._flameLabel(fc);
      ctx.fillText(flameLabel, gx, gy - 44);
      ctx.restore();
    }

    /* tube fill path helper */
    const tubePath = (fromY, toY) => {
      const arcR = Math.min(rBot, Math.max(2, (toY - fromY) * 0.5));
      ctx.beginPath();
      ctx.moveTo(tx, fromY);
      ctx.lineTo(tx, toY - arcR);
      ctx.arcTo(tx, toY, tx + rBot, toY, arcR);
      ctx.arcTo(tx + tubeW, toY, tx + tubeW, toY - arcR, arcR);
      ctx.lineTo(tx + tubeW, fromY);
      ctx.closePath();
    };

    /* solution */
    const solTop = tTop + tH * 0.06;
    ctx.fillStyle = ts2.solColor || 'rgba(100,180,255,0.15)';
    tubePath(solTop, tBot);
    ctx.fill();

    /* precipitate layer */
    if (ts2.precipColor && ts2.precipH > 0) {
      const ph = ts2.precipH;
      ctx.globalAlpha = 0.85;
      ctx.fillStyle = ts2.precipColor;
      tubePath(tBot - ph, tBot);
      ctx.fill();
      ctx.globalAlpha = 1;

      /* precip label when settled */
      if (tp.precip.every(p => p.done)) {
        const lastPrecipEntry = [...this._log].reverse().find(l =>
          l.tubeIdx === i && (() => {
            const rx2 = this._getIonForTube(i);
            if (!rx2) return false;
            const rxn = rx2.reactions[l.reagentId];
            return rxn && rxn.type === 'precip' && rxn.precipLabel;
          })()
        );
        if (lastPrecipEntry) {
          const ion2 = this._getIonForTube(i);
          const rxn2 = ion2 ? ion2.reactions[lastPrecipEntry.reagentId] : null;
          if (rxn2 && rxn2.precipLabel) {
            ctx.save();
            ctx.font = 'bold 11px Manrope,sans-serif';
            const lc = ts2.precipColor === '#111111' ? '#666' : ts2.precipColor;
            ctx.fillStyle = lc;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(rxn2.precipLabel, tx + tubeW / 2, tBot - ph / 2);
            ctx.restore();
          }
        }
      }
    }

    /* drop particles */
    tp.drops.forEach(d => {
      ctx.globalAlpha = d.alpha;
      ctx.fillStyle = d.color;
      ctx.beginPath();
      ctx.arc(d.x, d.y, 5, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = d.color + '66';
      ctx.beginPath();
      ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
    });

    /* precip flying */
    tp.precip.filter(p => !p.done).forEach(p => {
      ctx.globalAlpha = 0.8;
      ctx.fillStyle = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
    });

    /* gas bubbles */
    tp.gas.forEach(p => {
      ctx.globalAlpha = p.alpha;
      ctx.strokeStyle = p.color;
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
      ctx.stroke();
      ctx.globalAlpha = 1;
    });

    /* gas label */
    if (ts2.gasLabel && tp.gas.length > 0) {
      ctx.save();
      ctx.font = 'bold 11px Manrope,sans-serif';
      ctx.fillStyle = '#FFFFAA';
      ctx.textAlign = 'left';
      ctx.textBaseline = 'middle';
      ctx.fillText(ts2.gasLabel, tx + tubeW + 5, tTop + tH * 0.2);
      ctx.restore();
    }

    /* glass outline */
    ctx.save();
    ctx.shadowColor = isSample ? 'rgba(255,209,102,0.25)' : 'rgba(155,93,229,0.25)';
    ctx.shadowBlur = 10;
    ctx.strokeStyle = isSample ? 'rgba(255,209,102,0.75)' : 'rgba(200,215,255,0.6)';
    ctx.lineWidth = isSample ? 2.5 : 2;
    ctx.beginPath();
    ctx.moveTo(tx, tTop);
    ctx.lineTo(tx, tBot - rBot);
    ctx.arcTo(tx, tBot, tx + rBot, tBot, rBot);
    ctx.arcTo(tx + tubeW, tBot, tx + tubeW, tBot - rBot, rBot);
    ctx.lineTo(tx + tubeW, tTop);
    ctx.stroke();
    ctx.restore();

    /* rim */
    ctx.strokeStyle = isSample ? 'rgba(255,209,102,0.4)' : 'rgba(200,215,255,0.3)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(tx - 4, tTop);
    ctx.lineTo(tx + tubeW + 4, tTop);
    ctx.stroke();

    /* glass shine */
    const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.38, 0);
    shine.addColorStop(0, 'rgba(255,255,255,0.10)');
    shine.addColorStop(1, 'rgba(255,255,255,0)');
    ctx.fillStyle = shine;
    ctx.beginPath();
    ctx.moveTo(tx + 3, tTop + 3);
    ctx.lineTo(tx + tubeW * 0.30, tTop + 3);
    ctx.lineTo(tx + tubeW * 0.30, tBot - tubeW * 0.22);
    ctx.lineTo(tx + 3, tBot - tubeW * 0.22);
    ctx.closePath();
    ctx.fill();

    /* label below tube */
    const labelY = tBot + 20;
    ctx.save();
    ctx.font = '700 12px Manrope,sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    if (isSample) {
      ctx.fillStyle = '#FFD166';
      ctx.fillText('Образец', tx + tubeW / 2, labelY);
      if (this._mode === 'train') {
        ctx.font = '600 10px Manrope,sans-serif';
        ctx.fillStyle = 'rgba(255,209,102,0.6)';
        ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
      }
    } else {
      ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.78)';
      ctx.fillText('Проб' + (i + 1), tx + tubeW / 2, labelY);
      if (this._mode === 'exam') {
        ctx.font = '600 10px Manrope,sans-serif';
        ctx.fillStyle = isActive ? 'rgba(76,201,240,0.85)' : 'rgba(255,255,255,0.45)';
        ctx.fillText('(?)', tx + tubeW / 2, labelY + 14);
      } else {
        /* show known ion label in free / train modes */
        const knownIon = (this._helperIons || [])[i];
        if (knownIon) {
          ctx.font = '700 11px Manrope,sans-serif';
          ctx.fillStyle = isActive ? '#4CC9F0' : 'rgba(255,255,255,0.6)';
          ctx.fillText(knownIon.label, tx + tubeW / 2, labelY + 14);
        }
      }
    }
    ctx.restore();
  }

  _getIonForTube(tubeIdx) {
    const isSample = tubeIdx === this._tubeCount;
    if (this._mode === 'train' && isSample) return this._targetIon;
    if (this._mode === 'exam' && !isSample) return this._examIons[tubeIdx] || null;
    if (this._mode !== 'exam' && !isSample) return (this._helperIons || [])[tubeIdx] || null;
    return null;
  }

  _flameLabel(fc) {
    if (fc === '#FFD700') return 'Жёлтое — Na⁺';
    if (fc === '#CC00FF') return 'Фиолет. — K⁺';
    if (fc === '#CC4400') return 'Красное — Ca²⁺';
    if (fc === '#00DD00') return 'Зелёное — Ba²⁺';
    if (fc === '#00BB44') return 'Зелёное — Cu²⁺';
    return 'Окрашивание пламени';
  }

  /* ── Public API ──────────────────────────────────────────────── */
  stop() {
    if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
  }

  destroy() {
    this.stop();
    this._container.innerHTML = '';
  }
}

/* ── helpers ─────────────────────────────────────────────────────── */
function _esc(s) {
  if (!s) return '';
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

/* ── lab UI init ─────────────────────────────────────────────────── */
var qualSim = null;

function _openQualAnalysis() {
  document.getElementById('sim-topbar-title').textContent = 'Качественный анализ';
  _simShow('sim-qualanalysis');
  _registerSimState('qualanalysis', () => null, () => null);
  if (typeof _embedMode !== 'undefined' && _embedMode) _startStateEmit('qualanalysis');
  requestAnimationFrame(() => requestAnimationFrame(() => {
    const wrap = document.getElementById('qualanalysis-wrap');
    if (!qualSim) {
      qualSim = new QualAnalysisSim(wrap);
    } else {
      qualSim._resizeFit();
    }
  }));
}
