Files
Learn_System/frontend/js/labs/qualanalysis.js
T
Maxim Dolgolyov 9aa8c76932 refactor(labs): полная переработка стехиометрии и качественных реакций
- Стехиометрия → 4-шаговый wizard (Реакция → Количества → Лимит → Продукты), KaTeX в displayMode, крупные карточки
- Качественные реакции → центрированная сцена с большой пробиркой, журнал справа 290px, нижняя полка реагентов, убран список ионов
- Контраст: основной текст rgba(.92), вторичный (.7), шрифты от .85rem
2026-05-26 15:51:25 +03:00

1311 lines
61 KiB
JavaScript

'use strict';
/* ════════════════════════════════════════════════════════════════════
QualAnalysisSim — Качественный анализ катионов и анионов
Редизайн: centered tube, large log panel, reagent shelf bottom
════════════════════════════════════════════════════════════════════ */
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 },
},
indicators: { litmus: 'красный', methylorange: 'красный', phenolphthalein: 'бесцветный' }
},
{
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 },
},
indicators: { litmus: 'синий', methylorange: 'жёлтый', phenolphthalein: 'малиновый' }
},
/* АНИОНЫ */
{
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' },
];
/* ── constructor ─────────────────────────────────────────────── */
constructor(container) {
this._container = container;
this._mode = 'identify';
this._targetIon = null;
this._log = [];
this._answered = false;
this._dropAnim = [];
this._precipParticles = [];
this._gasParticles = [];
this._raf = null;
this._tubeState = {
color: null,
precipColor: null,
precipH: 0,
gasLabel: null,
flameColor: null,
solColor: 'rgba(100,180,255,0.18)'
};
this._score = 0;
this._lastT = 0;
this._build();
this._bindEvents();
this._startMode('identify');
}
/* ── 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',
].join(';');
/* ── TOP BAR ── */
const tb = document.createElement('div');
tb.style.cssText = [
'display:flex',
'align-items:center',
'gap:10px',
'padding:10px 16px',
'border-bottom:1px solid rgba(255,255,255,0.09)',
'flex-shrink:0',
'flex-wrap:wrap',
'background:rgba(255,255,255,0.02)',
].join(';');
/* mode tabs */
const tabsWrap = document.createElement('div');
tabsWrap.style.cssText = 'display:flex;gap:6px';
const makeTab = (id, text) => {
const b = document.createElement('button');
b.id = id;
b.textContent = text;
b.style.cssText = [
'padding:8px 20px',
'border-radius:10px',
'font-size:1rem',
'font-weight:700',
'cursor:pointer',
'transition:all .15s',
'border:1px solid rgba(255,255,255,0.12)',
'background:rgba(255,255,255,0.04)',
'color:rgba(255,255,255,0.55)',
].join(';');
return b;
};
const btnIdentify = makeTab('qa-btn-identify', 'Определить ион');
const btnUnknown = makeTab('qa-btn-unknown', 'Неизвестное вещество');
tabsWrap.appendChild(btnIdentify);
tabsWrap.appendChild(btnUnknown);
tb.appendChild(tabsWrap);
const spacer = document.createElement('div');
spacer.style.cssText = 'flex:1';
tb.appendChild(spacer);
/* score */
const scoreWrap = document.createElement('span');
scoreWrap.style.cssText = 'font-size:1rem;color:rgba(255,255,255,0.7);font-weight:600';
scoreWrap.innerHTML = 'Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span>';
tb.appendChild(scoreWrap);
/* new question button */
const btnNew = document.createElement('button');
btnNew.id = 'qa-btn-new';
btnNew.textContent = 'Новый вопрос';
btnNew.style.cssText = [
'padding:8px 18px',
'border-radius:10px',
'border:1px solid rgba(6,214,224,0.45)',
'background:rgba(6,214,224,0.1)',
'color:#06D6E0',
'font-size:.95rem',
'font-weight:700',
'cursor:pointer',
'transition:background .15s',
].join(';');
tb.appendChild(btnNew);
this._container.appendChild(tb);
/* ── MAIN ROW: scene + log ── */
const mainRow = document.createElement('div');
mainRow.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden';
this._container.appendChild(mainRow);
/* CENTER SCENE */
const scene = document.createElement('div');
scene.style.cssText = [
'flex:1',
'display:flex',
'flex-direction:column',
'min-width:0',
'overflow:hidden',
'position:relative',
].join(';');
mainRow.appendChild(scene);
/* canvas wrapper — takes all available height */
const canvasWrap = document.createElement('div');
canvasWrap.style.cssText = 'flex:1;position:relative;min-height:0;overflow:hidden';
scene.appendChild(canvasWrap);
const canvas = document.createElement('canvas');
canvas.id = 'qa-canvas';
canvas.style.cssText = 'display:block;width:100%;height:100%;cursor:crosshair';
canvasWrap.appendChild(canvas);
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
/* answer card inside scene, below canvas area */
const ansCard = document.createElement('div');
ansCard.id = 'qa-anscard';
ansCard.style.cssText = [
'flex-shrink:0',
'padding:10px 16px',
'border-top:1px solid rgba(255,255,255,0.08)',
'display:flex',
'align-items:center',
'gap:10px',
'flex-wrap:wrap',
'background:rgba(255,255,255,0.025)',
].join(';');
ansCard.innerHTML = [
'<span id="qa-question" style="font-size:.95rem;color:#B0A0FF;flex:1;min-width:160px">',
'Добавляй реагенты и определи ион в пробирке',
'</span>',
'<select id="qa-answer-sel" style="',
'padding:7px 12px;',
'border-radius:9px;',
'border:1px solid rgba(255,255,255,0.18);',
'background:#1a1a2e;',
'color:#E0E0FF;',
'font-size:.92rem;',
'cursor:pointer;',
'outline:none',
'"></select>',
'<button id="qa-submit" style="',
'padding:7px 18px;',
'border-radius:9px;',
'border:none;',
'background:linear-gradient(135deg,#9B5DE5,#06D6E0);',
'color:#fff;',
'font-size:.92rem;',
'font-weight:700;',
'cursor:pointer',
'">Ответить</button>',
'<div id="qa-verdict" style="',
'display:none;',
'font-size:.92rem;',
'font-weight:700;',
'padding:6px 14px;',
'border-radius:9px',
'"></div>',
].join('');
scene.appendChild(ansCard);
/* RIGHT PANEL — log */
const logPanel = document.createElement('div');
logPanel.style.cssText = [
'width:290px',
'flex-shrink:0',
'border-left:1px solid rgba(255,255,255,0.08)',
'display:flex',
'flex-direction:column',
'overflow:hidden',
'background:rgba(255,255,255,0.015)',
].join(';');
mainRow.appendChild(logPanel);
const logHeader = document.createElement('div');
logHeader.style.cssText = [
'padding:12px 14px 8px',
'font-size:.82rem',
'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.07)',
'flex-shrink:0',
].join(';');
logHeader.textContent = 'Журнал наблюдений';
logPanel.appendChild(logHeader);
const logScroll = document.createElement('div');
logScroll.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px';
logPanel.appendChild(logScroll);
const logList = document.createElement('div');
logList.id = 'qa-log';
logList.style.cssText = 'display:flex;flex-direction:column;gap:6px';
logScroll.appendChild(logList);
/* empty state hint */
const logHint = document.createElement('div');
logHint.id = 'qa-log-hint';
logHint.style.cssText = [
'font-size:.88rem',
'color:rgba(255,255,255,0.28)',
'text-align:center',
'margin-top:20px',
'padding:0 10px',
'line-height:1.5',
].join(';');
logHint.textContent = 'Нажимай реагенты снизу — здесь появятся результаты реакций';
logScroll.appendChild(logHint);
/* ── REAGENT SHELF (bottom) ── */
const shelf = document.createElement('div');
shelf.id = 'qa-shelf';
shelf.style.cssText = [
'flex-shrink:0',
'border-top:1px solid rgba(255,255,255,0.09)',
'padding:10px 14px',
'background:rgba(255,255,255,0.025)',
'display:flex',
'flex-wrap:wrap',
'gap:7px',
'align-items:center',
].join(';');
const shelfLabel = document.createElement('span');
shelfLabel.style.cssText = [
'font-size:.75rem',
'font-weight:700',
'text-transform:uppercase',
'letter-spacing:.06em',
'color:rgba(255,255,255,0.4)',
'margin-right:4px',
'flex-shrink:0',
].join(';');
shelfLabel.textContent = 'Реагенты:';
shelf.appendChild(shelfLabel);
QualAnalysisSim.REAGENTS.forEach(r => {
const btn = document.createElement('button');
btn.className = 'qa-reagent-btn';
btn.dataset.reagent = r.id;
btn.title = r.label;
btn.style.cssText = [
'padding:7px 14px',
'border-radius:9px',
`border:1px solid ${r.color}55`,
`background:${r.color}18`,
`color:${r.color}`,
'font-size:.95rem',
'font-weight:700',
'cursor:pointer',
'transition:background .15s, transform .1s',
'min-width:60px',
'text-align:center',
].join(';');
btn.textContent = r.label;
btn.addEventListener('mouseenter', () => {
btn.style.background = r.color + '33';
btn.style.transform = 'translateY(-1px)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = r.color + '18';
btn.style.transform = '';
});
shelf.appendChild(btn);
});
this._container.appendChild(shelf);
this._resizeFit();
}
_resizeFit() {
const c = this._canvas;
const p = c.parentElement;
if (!p) return;
const rect = p.getBoundingClientRect();
const w = rect.width || p.clientWidth || 400;
const h = rect.height || p.clientHeight || 360;
c.width = Math.round(w);
c.height = Math.round(h);
this._W = c.width;
this._H = c.height;
this._drawTube();
}
/* ── Tab highlight ───────────────────────────────────────────── */
_highlightMode(mode) {
const bi = document.getElementById('qa-btn-identify');
const bu = document.getElementById('qa-btn-unknown');
const activeStyle = [
'border:1px solid rgba(155,93,229,0.65)',
'background:rgba(155,93,229,0.2)',
'color:#D0A0FF',
].join(';');
const inactiveStyle = [
'border:1px solid rgba(255,255,255,0.12)',
'background:rgba(255,255,255,0.04)',
'color:rgba(255,255,255,0.55)',
].join(';');
/* reapply only the color/border parts by toggling a known block */
[bi, bu].forEach(btn => {
const isActive = (btn === bi && mode === 'identify') || (btn === bu && mode === 'unknown');
btn.style.border = isActive ? '1px solid rgba(155,93,229,0.65)' : '1px solid rgba(255,255,255,0.12)';
btn.style.background = isActive ? 'rgba(155,93,229,0.2)' : 'rgba(255,255,255,0.04)';
btn.style.color = isActive ? '#D0A0FF' : 'rgba(255,255,255,0.55)';
});
}
/* ── Events ──────────────────────────────────────────────────── */
_bindEvents() {
const $ = id => document.getElementById(id);
$('qa-btn-identify').addEventListener('click', () => this._startMode('identify'));
$('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown'));
$('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));
$('qa-submit').addEventListener('click', () => this._submitAnswer());
this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (this._answered) return;
this._applyReagent(btn.dataset.reagent);
});
btn.setAttribute('draggable', 'true');
btn.addEventListener('dragstart', e => {
this._dragReagent = btn.dataset.reagent;
e.dataTransfer.effectAllowed = 'copy';
});
});
this._canvas.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
this._canvas.addEventListener('drop', e => {
e.preventDefault();
if (this._dragReagent && !this._answered) {
this._applyReagent(this._dragReagent);
this._dragReagent = null;
}
});
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => this._resizeFit());
ro.observe(this._canvas.parentElement || this._container);
}
}
/* ── Mode start ──────────────────────────────────────────────── */
_startMode(mode) {
this._mode = mode;
this._log = [];
this._answered = false;
this._dropAnim = [];
this._precipParticles = [];
this._gasParticles = [];
this._dragReagent = null;
const ions = QualAnalysisSim.IONS;
this._targetIon = ions[Math.floor(Math.random() * ions.length)];
this._tubeState = {
color: null,
precipColor: null,
precipH: 0,
gasLabel: null,
flameColor: null,
solColor: this._targetIon.solColor || 'rgba(100,180,255,0.18)',
};
/* reset DOM */
document.getElementById('qa-log').innerHTML = '';
const hint = document.getElementById('qa-log-hint');
if (hint) hint.style.display = '';
document.getElementById('qa-verdict').style.display = 'none';
document.getElementById('qa-verdict').textContent = '';
this._highlightMode(mode);
this._updateAnswerSelect();
this._populateQuestion(mode);
this._drawTube();
if (!this._raf) this._animLoop(performance.now());
}
_populateQuestion(mode) {
const q = document.getElementById('qa-question');
if (mode === 'identify') {
q.textContent = 'Добавляй реагенты — определи ион в пробирке, выбери ответ и нажми «Ответить»';
} else {
q.textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь';
}
}
_updateAnswerSelect() {
const sel = document.getElementById('qa-answer-sel');
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 ───────────────────────────────────────────── */
_applyReagent(reagentId) {
const ion = this._targetIon;
const rxn = ion.reactions[reagentId];
if (!rxn) return;
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.id === reagentId);
const rLabel = rInfo ? rInfo.label : reagentId;
/* sounds */
if (window.LabFX) {
LabFX.sound.play(rxn.type === 'gas' ? 'fizz' : 'pour');
}
/* update tube state */
if (rxn.type === 'flame') {
this._tubeState.flameColor = rxn.color;
setTimeout(() => { this._tubeState.flameColor = null; this._drawTube(); }, 2000);
} else if (rxn.type === 'precip' && rxn.color) {
this._tubeState.precipColor = rxn.color;
this._tubeState.precipH = 0;
this._precipParticles = this._spawnPrecipParticles(rxn.color);
} else if (rxn.type === 'solution' && rxn.color) {
this._tubeState.solColor = rxn.color;
} else if (rxn.type === 'gas' && rxn.color) {
this._tubeState.gasLabel = rxn.gasLabel || '↑';
this._gasParticles = this._spawnGasParticles(rxn.color);
}
/* drop animation — from top center */
this._dropAnim.push({
x: this._W * 0.5,
y: 20,
vy: 2.5,
color: rInfo ? rInfo.color : '#FFF',
alpha: 1,
done: false,
});
/* log */
const entry = { reagent: rLabel, obs: rxn.obs, positive: rxn.positive, excess: rxn.excess || null };
this._log.push(entry);
this._renderLogEntry(entry);
}
_spawnPrecipParticles(color) {
const cx = this._W * 0.5;
const cy = this._H * 0.55;
const ps = [];
for (let i = 0; i < 32; i++) {
ps.push({
x: cx + (Math.random() - 0.5) * 80,
y: cy,
vy: 0.6 + Math.random() * 2,
vx: (Math.random() - 0.5) * 2,
color,
r: 3 + Math.random() * 4,
done: false,
});
}
return ps;
}
_spawnGasParticles(color) {
const cx = this._W * 0.5;
const cy = this._H * 0.4;
const ps = [];
for (let i = 0; i < 26; i++) {
ps.push({
x: cx + (Math.random() - 0.5) * 50,
y: cy,
vy: -(1 + Math.random() * 1.8),
vx: (Math.random() - 0.5) * 1.2,
color,
r: 4 + Math.random() * 5,
alpha: 0.9,
done: false,
});
}
return ps;
}
/* ── Log rendering ───────────────────────────────────────────── */
_renderLogEntry(entry) {
const hint = document.getElementById('qa-log-hint');
if (hint) hint.style.display = 'none';
const log = document.getElementById('qa-log');
const n = log.children.length + 1;
const col = entry.positive ? '#5EF08E' : 'rgba(255,255,255,0.4)';
const card = document.createElement('div');
card.style.cssText = [
'padding:9px 11px',
'border-radius:10px',
`border-left:3px solid ${col}`,
'background:rgba(255,255,255,0.04)',
'border-top:1px solid rgba(255,255,255,0.07)',
'border-right:1px solid rgba(255,255,255,0.07)',
'border-bottom:1px solid rgba(255,255,255,0.07)',
'display:flex',
'flex-direction:column',
'gap:3px',
].join(';');
/* reagent row */
const topRow = document.createElement('div');
topRow.style.cssText = 'display:flex;align-items:center;gap:7px;font-size:.9rem';
const numSpan = document.createElement('span');
numSpan.style.cssText = 'font-size:.78rem;color:rgba(255,255,255,0.35);font-weight:600;min-width:16px';
numSpan.textContent = n + '.';
const reagentSpan = document.createElement('span');
/* find color for this reagent */
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent);
const rColor = rInfo ? rInfo.color : '#AAA';
reagentSpan.style.cssText = `color:${rColor};font-weight:700;font-size:.92rem`;
reagentSpan.textContent = _esc(entry.reagent);
const arrowSpan = document.createElement('span');
arrowSpan.style.cssText = 'color:rgba(255,255,255,0.35);font-size:.85rem';
arrowSpan.textContent = '→';
const dotSpan = document.createElement('span');
dotSpan.style.cssText = `width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0;margin-left:auto`;
topRow.appendChild(numSpan);
topRow.appendChild(reagentSpan);
topRow.appendChild(arrowSpan);
topRow.appendChild(dotSpan);
card.appendChild(topRow);
/* observation text */
const obsDiv = document.createElement('div');
obsDiv.style.cssText = 'font-size:.88rem;color:rgba(255,255,255,0.8);line-height:1.45;padding-left:23px';
obsDiv.textContent = entry.obs;
card.appendChild(obsDiv);
/* excess note */
if (entry.excess) {
const exDiv = document.createElement('div');
exDiv.style.cssText = 'font-size:.8rem;color:#FFD166;padding-left:23px;margin-top:1px';
exDiv.textContent = entry.excess;
card.appendChild(exDiv);
}
log.appendChild(card);
log.parentElement.scrollTop = log.parentElement.scrollHeight;
}
/* ── Submit answer ───────────────────────────────────────────── */
_submitAnswer() {
if (this._answered) return;
const sel = document.getElementById('qa-answer-sel');
const chosen = sel.value;
if (!chosen) return;
this._answered = true;
const correct = chosen === this._targetIon.id;
const verdict = document.getElementById('qa-verdict');
verdict.style.display = 'block';
if (correct) {
this._score++;
document.getElementById('qa-score').textContent = this._score;
verdict.textContent = 'Верно! Это ' + this._targetIon.label;
verdict.style.background = 'rgba(94,240,142,0.15)';
verdict.style.color = '#5EF08E';
verdict.style.border = '1px solid rgba(94,240,142,0.35)';
if (window.LabFX) LabFX.sound.play('chime');
} else {
const correctIon = QualAnalysisSim.IONS.find(i => i.id === this._targetIon.id);
verdict.textContent = 'Неверно. Правильный ответ: ' + (correctIon ? correctIon.label : this._targetIon.id);
verdict.style.background = 'rgba(239,71,111,0.12)';
verdict.style.color = '#EF476F';
verdict.style.border = '1px solid rgba(239,71,111,0.35)';
}
}
/* ── 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;
this._dropAnim = this._dropAnim.filter(d => {
if (d.done) return false;
d.y += d.vy;
d.vy += 0.25;
const floor = this._H * 0.52;
if (d.y > floor) { d.done = true; needDraw = true; return false; }
needDraw = true;
return true;
});
this._precipParticles.forEach(p => {
if (!p.done) {
p.x += p.vx;
p.y += p.vy;
p.vy *= 0.98;
const floor = this._H * 0.83;
if (p.y >= floor) {
p.y = floor;
p.vy = 0;
p.vx = 0;
p.done = true;
this._tubeState.precipH = Math.min(this._tubeState.precipH + 2.5, 38);
}
needDraw = true;
}
});
this._gasParticles = this._gasParticles.filter(p => {
if (p.done) return false;
p.y += p.vy;
p.x += p.vx;
p.alpha -= dt * 0.6;
if (p.alpha <= 0 || p.y < 0) { p.done = true; return false; }
needDraw = true;
return true;
});
if (needDraw || this._tubeState.flameColor) this._drawTube();
}
/* ── Draw ────────────────────────────────────────────────────── */
_drawTube() {
const ctx = this._ctx;
const W = this._W || 400;
const H = this._H || 400;
ctx.clearRect(0, 0, W, H);
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* subtle bench surface */
ctx.fillStyle = 'rgba(255,255,255,0.025)';
ctx.fillRect(0, H * 0.88, W, H * 0.12);
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H * 0.88);
ctx.lineTo(W, H * 0.88);
ctx.stroke();
/* tube dimensions — large, centered */
const tubeW = Math.min(120, W * 0.22);
const tx = W * 0.5 - tubeW * 0.5;
const tTop = H * 0.1;
const tBot = H * 0.86;
const tH = tBot - tTop;
const rBot = tubeW * 0.5; /* bottom arc radius */
/* ── flame halo ── */
if (this._tubeState.flameColor) {
const fc = this._tubeState.flameColor;
const gx = W * 0.5;
const gy = tTop - 30;
const gr = ctx.createRadialGradient(gx, gy, 6, gx, gy, 110);
gr.addColorStop(0, fc + 'DD');
gr.addColorStop(0.35, fc + '55');
gr.addColorStop(1, fc + '00');
ctx.fillStyle = gr;
ctx.beginPath();
ctx.arc(gx, gy, 110, 0, Math.PI * 2);
ctx.fill();
/* flame label */
ctx.save();
ctx.font = 'bold 14px Manrope,sans-serif';
ctx.fillStyle = fc;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const flameLabel = (
fc === '#FFD700' ? 'Жёлтое пламя — Na⁺' :
fc === '#CC00FF' ? 'Фиолетовое — K⁺' :
fc === '#CC4400' ? 'Кирпично-красное — Ca²⁺' :
fc === '#00DD00' ? 'Зелёное — Ba²⁺' :
fc === '#00BB44' ? 'Зелёное пламя — Cu²⁺' :
'Окрашивание пламени'
);
ctx.fillText(flameLabel, W * 0.5, tTop - 55);
ctx.restore();
}
/* solution fill path helper */
const tubePath = (fromY, toY) => {
const arcR = Math.min(rBot, (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;
const solBot = tBot;
ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)';
tubePath(solTop, solBot);
ctx.fill();
/* precipitate layer */
if (this._tubeState.precipColor && this._tubeState.precipH > 0) {
const ph = this._tubeState.precipH;
const py = solBot - ph;
ctx.globalAlpha = 0.88;
ctx.fillStyle = this._tubeState.precipColor;
tubePath(py, solBot);
ctx.fill();
ctx.globalAlpha = 1;
/* label when settled */
if (this._precipParticles.every(p => p.done)) {
const lastPrecip = [...this._log].reverse().find(l => {
const ri = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent);
if (!ri) return false;
const rx2 = this._targetIon.reactions[ri.id];
return rx2 && rx2.type === 'precip' && rx2.precipLabel;
});
if (lastPrecip) {
const ri = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent);
const rx2 = ri ? this._targetIon.reactions[ri.id] : null;
if (rx2 && rx2.precipLabel) {
ctx.save();
ctx.font = 'bold 13px Manrope,sans-serif';
const labelCol = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor;
ctx.fillStyle = labelCol;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(rx2.precipLabel, W * 0.5, solBot - ph * 0.5);
ctx.restore();
}
}
}
}
/* drop particles */
this._dropAnim.forEach(d => {
ctx.globalAlpha = d.alpha;
ctx.fillStyle = d.color;
ctx.beginPath();
ctx.arc(d.x, d.y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = d.color + '77';
ctx.beginPath();
ctx.arc(d.x, d.y - 10, 4, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
/* precipitate flying particles */
this._precipParticles.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 */
this._gasParticles.forEach(p => {
ctx.globalAlpha = p.alpha;
ctx.strokeStyle = p.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
});
/* gas label */
if (this._tubeState.gasLabel) {
ctx.save();
ctx.font = 'bold 15px Manrope,sans-serif';
ctx.fillStyle = '#FFFFAA';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(this._tubeState.gasLabel, tx + tubeW + 12, tTop + tH * 0.2);
ctx.restore();
}
/* ── tube glass outline ── */
ctx.save();
ctx.shadowColor = 'rgba(155,93,229,0.35)';
ctx.shadowBlur = 14;
ctx.strokeStyle = 'rgba(200,215,255,0.6)';
ctx.lineWidth = 2.5;
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 = 'rgba(200,215,255,0.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tx - 5, tTop);
ctx.lineTo(tx + tubeW + 5, tTop);
ctx.stroke();
/* glass shine */
const shine = ctx.createLinearGradient(tx, 0, tx + tubeW * 0.4, 0);
shine.addColorStop(0, 'rgba(255,255,255,0.12)');
shine.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = shine;
ctx.beginPath();
ctx.moveTo(tx + 4, tTop + 4);
ctx.lineTo(tx + tubeW * 0.32, tTop + 4);
ctx.lineTo(tx + tubeW * 0.32, tBot - tubeW * 0.25);
ctx.lineTo(tx + 4, tBot - tubeW * 0.25);
ctx.closePath();
ctx.fill();
/* mode watermark */
ctx.save();
ctx.font = '700 11px Manrope,sans-serif';
ctx.fillStyle = 'rgba(155,93,229,0.35)';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(
this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР',
W * 0.5,
H - 4
);
ctx.restore();
}
/* ── Public API ──────────────────────────────────────────────── */
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
destroy() {
this.stop();
}
}
/* ── 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();
}
}));
}