'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 = 'Счёт: 0';
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 = [
'Как пользоваться',
'
',
'- Перетащи реагент в пробирку или нажми реагент (активная пробирка выделена синим)
',
'- Наблюдай реакцию на холсте и читай журнал
',
'- Свободно — просто эксперименты, нет задания
',
'- Тренировка — один неизвестный ион в Образце, определи его
',
'- Экзамен — 4 неизвестных иона (по одному в каждой пробирке), без подсказок
',
'- Используй пробирки Проб1-4 как вспомогательные для сравнения
',
'
',
].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 = '';
['Катионы', 'Анионы'].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, '&')
.replace(//g, '>');
}
/* ── 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();
}
}));
}