Files
Learn_System/frontend/js/labs/qualanalysis.js
T
Maxim Dolgolyov ea2526dc73 feat(labs): 4 школьные хим. симы + визуальная прокачка лаборатории
4 НОВЫЕ СИМЫ (школьная программа 8-11 классов):

Органика (organic.js, 1545 строк):
- Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds
- Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат
- IUPAC-имена для C1-C10
- Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл
- 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂

Периодическая таблица (periodic.js, 118 элементов):
- Стандартный вид 18×9 + лантаноиды/актиноиды
- Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип
- Боровская модель электронных оболочек (анимированная)
- Подсветка: 11 типов / s/p/d/f-блоки / без подсветки
- Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип)
- Поиск по символу/имени/Z/массе

Качественный анализ (qualanalysis.js, 24 иона):
- 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH
- 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO
- 9 реактивов + пламя
- 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений
- Анимация капли, осадка с цветом, газовых пузырей, пламени

Растворы (solutions.js, 4 режима):
- Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта
- Разбавление с before/after визуализацией
- Смешивание двух растворов с правилом рычага
- Кривые растворимости 8 веществ + задача перекристаллизации
- 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...)

ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file):

12 функций школьной лабораторной графики:
- drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой
- drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя
- animateGasBubbles / animatePrecipitateFall — анимация продуктов
- drawProductLabel — fade-in/out стрелка ↑/↓ с подписью
- drawEduTooltip — bubble с пояснением реакции
- drawDeskBackground / drawVesselShadow — лабораторный фон
- drawPHStrip — pH-индикаторная полоса с маркером

Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox
Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов,
educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask.
pH-полоса в titration.

Каталог теперь: 39 симуляций (было 35 + 4 новых).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:08:35 +03:00

1063 lines
58 KiB
JavaScript

'use strict';
/* ════════════════════════════════════════════════════════════════════
QualAnalysisSim — Качественный анализ катионов и анионов
Режим 1: «Определить ион» (guided identification)
Режим 2: «Неизвестное вещество» (drag-drop free experiment)
════════════════════════════════════════════════════════════════════ */
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'; // 'identify' | 'unknown'
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._dragReagent = null;
this._dragX = 0; this._dragY = 0;
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';
/* top toolbar */
const tb = document.createElement('div');
tb.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;flex-wrap:wrap';
tb.innerHTML = `
<button id="qa-btn-identify" class="qa-mode-btn" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF;font-size:.77rem;font-weight:700;cursor:pointer">Определить ион</button>
<button id="qa-btn-unknown" class="qa-mode-btn" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa;font-size:.77rem;font-weight:700;cursor:pointer">Неизвестное вещество</button>
<div style="flex:1"></div>
<span style="font-size:.75rem;color:#888">Счёт: <span id="qa-score" style="color:#FFD166;font-weight:800">0</span></span>
<button id="qa-btn-new" style="padding:5px 12px;border-radius:8px;border:1px solid rgba(6,214,224,0.4);background:rgba(6,214,224,0.1);color:#06D6E0;font-size:.75rem;font-weight:700;cursor:pointer">Новый вопрос</button>`;
this._container.appendChild(tb);
/* main area */
const main = document.createElement('div');
main.style.cssText = 'display:flex;flex:1;min-height:0;overflow:hidden';
this._container.appendChild(main);
/* left panel: log + reagents */
const left = document.createElement('div');
left.id = 'qa-left';
left.style.cssText = 'width:230px;display:flex;flex-direction:column;border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0;overflow:hidden';
main.appendChild(left);
/* reagent shelf */
const shelf = document.createElement('div');
shelf.id = 'qa-shelf';
shelf.style.cssText = 'padding:10px 8px 6px;border-bottom:1px solid rgba(255,255,255,0.07);flex-shrink:0';
shelf.innerHTML = '<div style="font-size:.7rem;color:#888;margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Реагенты</div>';
const shelfGrid = document.createElement('div');
shelfGrid.id = 'qa-shelf-grid';
shelfGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:4px';
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:4px 7px;border-radius:7px;border:1px solid ${r.color}44;background:${r.color}18;color:${r.color};font-size:.72rem;font-weight:700;cursor:pointer;transition:background .15s`;
btn.textContent = r.label;
shelfGrid.appendChild(btn);
});
shelf.appendChild(shelfGrid);
left.appendChild(shelf);
/* log */
const logWrap = document.createElement('div');
logWrap.style.cssText = 'flex:1;overflow-y:auto;padding:8px';
logWrap.innerHTML = '<div style="font-size:.7rem;color:#888;margin-bottom:5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Наблюдения</div>';
const logList = document.createElement('div');
logList.id = 'qa-log';
logList.style.cssText = 'display:flex;flex-direction:column;gap:4px';
logWrap.appendChild(logList);
left.appendChild(logWrap);
/* center: tube + canvas */
const center = document.createElement('div');
center.style.cssText = 'flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;position:relative;overflow:hidden';
main.appendChild(center);
const canvas = document.createElement('canvas');
canvas.id = 'qa-canvas';
canvas.style.cssText = 'display:block;cursor:crosshair';
center.appendChild(canvas);
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
/* answer bar */
const ansBar = document.createElement('div');
ansBar.id = 'qa-ansbar';
ansBar.style.cssText = 'padding:8px 14px;border-top:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap';
ansBar.innerHTML = `
<span id="qa-question" style="font-size:.8rem;color:#B0A0FF;flex:1">Добавляй реагенты и определи ион в пробирке</span>
<select id="qa-answer-sel" style="padding:5px 9px;border-radius:8px;border:1px solid rgba(255,255,255,0.15);background:#1a1a2e;color:#E0E0FF;font-size:.78rem;cursor:pointer"></select>
<button id="qa-submit" style="padding:5px 13px;border-radius:8px;border:none;background:linear-gradient(135deg,#9B5DE5,#06D6E0);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer">Ответить</button>
<div id="qa-verdict" style="display:none;font-size:.8rem;font-weight:700;padding:4px 10px;border-radius:8px"></div>`;
this._container.appendChild(ansBar);
/* right panel: ion reference list (mode 1) */
const right = document.createElement('div');
right.id = 'qa-right';
right.style.cssText = 'width:180px;border-left:1px solid rgba(255,255,255,0.07);overflow-y:auto;padding:8px;flex-shrink:0';
right.innerHTML = '<div style="font-size:.7rem;color:#888;margin-bottom:6px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Список ионов</div>';
const ionList = document.createElement('div');
ionList.id = 'qa-ionlist';
ionList.style.cssText = 'display:flex;flex-direction:column;gap:3px';
['Катионы','Анионы'].forEach(grp => {
const h = document.createElement('div');
h.style.cssText = 'font-size:.67rem;color:#666;text-transform:uppercase;letter-spacing:.05em;margin-top:6px;margin-bottom:2px';
h.textContent = grp;
ionList.appendChild(h);
QualAnalysisSim.IONS.filter(i => i.group === grp).forEach(ion => {
const d = document.createElement('div');
d.className = 'qa-ion-card';
d.dataset.id = ion.id;
d.style.cssText = 'font-size:.75rem;padding:3px 7px;border-radius:6px;border:1px solid rgba(255,255,255,0.07);background:rgba(255,255,255,0.03);cursor:default;color:#CCC';
d.textContent = ion.label;
ionList.appendChild(d);
});
});
right.appendChild(ionList);
main.appendChild(right);
this._resizeFit();
}
_resizeFit() {
const c = this._canvas;
const p = c.parentElement;
if (!p) return;
const w = p.clientWidth || 400;
const h = p.clientHeight || 360;
c.width = w;
c.height = h;
this._W = w; this._H = h;
this._drawTube();
}
/* ── Events ──────────────────────────────────────────────────── */
_bindEvents() {
const el = id => document.getElementById(id);
el('qa-btn-identify').addEventListener('click', () => this._startMode('identify'));
el('qa-btn-unknown').addEventListener('click', () => this._startMode('unknown'));
el('qa-btn-new').addEventListener('click', () => this._startMode(this._mode));
el('qa-submit').addEventListener('click', () => this._submitAnswer());
/* reagent buttons → click to apply */
this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (this._answered) return;
const rid = btn.dataset.reagent;
this._applyReagent(rid);
/* visual flash */
const col = btn.style.color;
btn.style.background = col + '44';
setTimeout(() => { btn.style.background = col + '18'; }, 200);
});
});
/* drag-and-drop reagent to canvas */
this._container.querySelectorAll('.qa-reagent-btn').forEach(btn => {
btn.addEventListener('dragstart', e => {
this._dragReagent = btn.dataset.reagent;
e.dataTransfer.effectAllowed = 'copy';
});
btn.setAttribute('draggable', 'true');
});
this._canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
this._canvas.addEventListener('drop', e => {
e.preventDefault();
if (this._dragReagent && !this._answered) {
const rect = this._canvas.getBoundingClientRect();
this._dragX = e.clientX - rect.left;
this._dragY = e.clientY - rect.top;
this._applyReagent(this._dragReagent);
this._dragReagent = null;
}
});
/* ResizeObserver */
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 = [];
/* pick random ion */
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 UI */
document.getElementById('qa-log').innerHTML = '';
document.getElementById('qa-verdict').style.display = 'none';
document.getElementById('qa-verdict').textContent = '';
this._highlightMode(mode);
this._updateAnswerSelect();
this._populateAnswerQuestion(mode);
this._updateIonHighlight(null);
this._drawTube();
if (!this._raf) this._animLoop(performance.now());
}
_populateAnswerQuestion(mode) {
if (mode === 'identify') {
document.getElementById('qa-question').textContent = 'Добавляй реагенты и определи ион в пробирке — выбери ответ и нажми «Ответить»';
} else {
document.getElementById('qa-question').textContent = 'Испытай неизвестный раствор реагентами, затем выбери ион и ответь';
}
}
_highlightMode(mode) {
const bi = document.getElementById('qa-btn-identify');
const bu = document.getElementById('qa-btn-unknown');
const active = 'border:1px solid rgba(155,93,229,0.6);background:rgba(155,93,229,0.18);color:#D0A0FF';
const inactive = 'border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:#aaa';
bi.style.cssText = bi.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'identify' ? active : inactive);
bu.style.cssText = bu.style.cssText.replace(/border:[^;]+;background:[^;]+;color:[^;]+/, mode === 'unknown' ? active : inactive);
}
_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;
/* LabFX sounds */
if (window.LabFX) {
if (rxn.type === 'gas') {
LabFX.sound.play('fizz');
} else {
LabFX.sound.play('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;
/* animate precip settling */
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 */
const cx = this._W * 0.5;
const cy = 60;
this._dropAnim.push({ x: cx, y: 20, vy: 2, color: rInfo ? rInfo.color : '#FFF', alpha: 1, done: false });
/* log entry */
const isPositive = rxn.positive;
const entry = { reagent: rLabel, obs: rxn.obs, positive: isPositive, excess: rxn.excess || null };
this._log.push(entry);
this._renderLogEntry(entry);
this._updateIonHighlight(this._log);
}
_spawnPrecipParticles(color) {
const cx = this._W * 0.5;
const cy = this._H * 0.6;
const ps = [];
for (let i = 0; i < 22; i++) {
ps.push({ x: cx + (Math.random() - 0.5) * 60, y: cy, vy: 0.5 + Math.random() * 1.5, vx: (Math.random() - 0.5) * 1.5, color, r: 2 + Math.random() * 3, 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 < 18; i++) {
ps.push({ x: cx + (Math.random() - 0.5) * 30, y: cy, vy: -(0.8 + Math.random() * 1.2), vx: (Math.random() - 0.5), color, r: 3 + Math.random() * 4, alpha: 0.85, done: false });
}
return ps;
}
_renderLogEntry(entry) {
const log = document.getElementById('qa-log');
const d = document.createElement('div');
const col = entry.positive ? '#5EF08E' : '#888';
d.style.cssText = `font-size:.72rem;padding:4px 6px;border-radius:6px;border-left:3px solid ${col};background:rgba(255,255,255,0.03);color:#CCC;line-height:1.4`;
d.innerHTML = `<span style="color:${col};font-weight:700">${_esc(entry.reagent)}</span>: ${_esc(entry.obs)}${entry.excess ? `<div style="color:#FFD166;font-size:.67rem;margin-top:2px">${_esc(entry.excess)}</div>` : ''}`;
log.appendChild(d);
log.scrollTop = log.scrollHeight;
}
/* highlight ions that are consistent with observations */
_updateIonHighlight(log) {
const cards = this._container.querySelectorAll('.qa-ion-card');
if (!log || log.length === 0) {
cards.forEach(c => { c.style.background = 'rgba(255,255,255,0.03)'; c.style.color = '#CCC'; c.style.borderColor = 'rgba(255,255,255,0.07)'; });
return;
}
cards.forEach(c => {
const ionId = c.dataset.id;
const ion = QualAnalysisSim.IONS.find(i => i.id === ionId);
if (!ion) return;
let compatible = true;
for (const entry of log) {
/* find reagent id from label */
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === entry.reagent || r.id === entry.reagent);
if (!rInfo) continue;
const expectedRxn = ion.reactions[rInfo.id];
if (!expectedRxn) continue;
/* if positive result observed but ion doesn't produce positive here */
if (entry.positive && !expectedRxn.positive) { compatible = false; break; }
}
if (compatible) {
c.style.background = 'rgba(155,93,229,0.12)';
c.style.color = '#D0A0FF';
c.style.borderColor = 'rgba(155,93,229,0.3)';
} else {
c.style.background = 'rgba(255,255,255,0.01)';
c.style.color = '#444';
c.style.borderColor = 'rgba(255,255,255,0.04)';
}
});
}
/* ── 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.3)';
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.3)';
}
/* highlight correct ion card */
const cards = this._container.querySelectorAll('.qa-ion-card');
cards.forEach(c => {
if (c.dataset.id === this._targetIon.id) {
c.style.background = 'rgba(94,240,142,0.15)';
c.style.color = '#5EF08E';
c.style.borderColor = '#5EF08E';
}
});
}
/* ── Animation loop ──────────────────────────────────────────── */
_animLoop(t) {
this._raf = requestAnimationFrame(ts => this._animLoop(ts));
const dt = Math.min((t - this._lastT) / 1000, 0.05);
this._lastT = t;
/* advance particles */
let needDraw = false;
this._dropAnim = this._dropAnim.filter(d => {
if (d.done) return false;
d.y += d.vy;
d.vy += 0.2;
if (d.y > this._H * 0.55) { 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.82;
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, 30); }
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.5;
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 || 360;
ctx.clearRect(0, 0, W, H);
/* background */
ctx.fillStyle = '#0D0D1A';
ctx.fillRect(0, 0, W, H);
/* bench surface */
ctx.fillStyle = 'rgba(255,255,255,0.03)';
ctx.fillRect(0, H * 0.87, W, H * 0.13);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H * 0.87); ctx.lineTo(W, H * 0.87); ctx.stroke();
/* tube dimensions */
const tx = W * 0.5 - 28;
const tw = 56;
const tTop = H * 0.15;
const tBot = H * 0.85;
const tH = tBot - tTop;
const r = 10;
/* flame halo */
if (this._tubeState.flameColor) {
const fc = this._tubeState.flameColor;
const grad = ctx.createRadialGradient(W * 0.5, tTop - 20, 5, W * 0.5, tTop - 20, 80);
grad.addColorStop(0, fc + 'CC');
grad.addColorStop(0.4, fc + '44');
grad.addColorStop(1, fc + '00');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(W * 0.5, tTop - 20, 80, 0, Math.PI * 2); ctx.fill();
/* flame label */
ctx.font = 'bold 12px Manrope,sans-serif';
ctx.fillStyle = fc;
ctx.textAlign = 'center';
const flameLabel = (() => {
if (fc === '#FFD700') return 'Жёлтое пламя — Na⁺';
if (fc === '#CC00FF') return 'Фиолетовое — K⁺';
if (fc === '#CC4400') return 'Кирпично-красное — Ca²⁺';
if (fc === '#00DD00') return 'Зелёное — Ba²⁺';
if (fc === '#00BB44') return 'Зелёное пламя';
return 'Окрашивание пламени';
})();
ctx.fillText(flameLabel, W * 0.5, tTop - 40);
}
/* tube shadow */
ctx.shadowColor = 'rgba(155,93,229,0.2)';
ctx.shadowBlur = 18;
/* solution fill */
const solTop = tTop + tH * 0.05;
const solBot = tBot - 5;
const solH = solBot - solTop;
ctx.shadowBlur = 0;
ctx.fillStyle = this._tubeState.solColor || 'rgba(100,180,255,0.18)';
ctx.beginPath();
ctx.moveTo(tx + r, solTop);
ctx.lineTo(tx + tw - r, solTop);
ctx.lineTo(tx + tw - r, solBot - r);
ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r);
ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r);
ctx.lineTo(tx + r, solTop);
ctx.closePath();
ctx.fill();
/* precipitate layer */
if (this._tubeState.precipColor && this._tubeState.precipH > 0) {
const ph = this._tubeState.precipH;
const py = solBot - ph;
ctx.fillStyle = this._tubeState.precipColor;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo(tx + r, py);
ctx.lineTo(tx + tw - r, py);
ctx.lineTo(tx + tw - r, solBot - r);
ctx.arcTo(tx + tw - r, solBot, tx + tw / 2, solBot, r);
ctx.arcTo(tx + r, solBot, tx + r, solBot - r, r);
ctx.lineTo(tx + r, py);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
/* precipitate label */
if (this._precipParticles && this._precipParticles.every(p => p.done)) {
ctx.font = 'bold 10px Manrope,sans-serif';
ctx.fillStyle = this._tubeState.precipColor === '#111111' ? '#888' : this._tubeState.precipColor;
ctx.textAlign = 'center';
/* find label from last positive precip reaction */
const lastPrecip = [...this._log].reverse().find(l => {
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === l.reagent || r.id === l.reagent);
if (!rInfo) return false;
const rxn = this._targetIon.reactions[rInfo.id];
return rxn && rxn.type === 'precip' && rxn.precipLabel;
});
if (lastPrecip) {
const rInfo = QualAnalysisSim.REAGENTS.find(r => r.label === lastPrecip.reagent || r.id === lastPrecip.reagent);
const rxn = rInfo ? this._targetIon.reactions[rInfo.id] : null;
if (rxn && rxn.precipLabel) {
ctx.fillText(rxn.precipLabel, W * 0.5, solBot - ph / 2 + 4);
}
}
}
}
/* falling drop particles */
this._dropAnim.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();
/* drop tail */
ctx.fillStyle = d.color + '88';
ctx.beginPath(); ctx.arc(d.x, d.y - 8, 3, 0, Math.PI * 2); ctx.fill();
ctx.globalAlpha = 1;
});
/* floating precipitate particles */
this._precipParticles.filter(p => !p.done).forEach(p => {
ctx.globalAlpha = 0.75;
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 = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.stroke();
ctx.globalAlpha = 1;
});
/* gas label above tube */
if (this._tubeState.gasLabel) {
ctx.font = 'bold 13px Manrope,sans-serif';
ctx.fillStyle = '#FFFFAA';
ctx.textAlign = 'center';
ctx.fillText(this._tubeState.gasLabel, W * 0.5 + 40, tTop + 20);
}
/* tube glass outline */
ctx.shadowColor = 'rgba(155,93,229,0.3)';
ctx.shadowBlur = 12;
ctx.strokeStyle = 'rgba(200,210,255,0.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tx, tTop);
ctx.lineTo(tx, tBot - r);
ctx.arcTo(tx, tBot, tx + r, tBot, r);
ctx.lineTo(tx + tw - r, tBot);
ctx.arcTo(tx + tw, tBot, tx + tw, tBot - r, r);
ctx.lineTo(tx + tw, tTop);
ctx.stroke();
ctx.shadowBlur = 0;
/* tube opening rim */
ctx.strokeStyle = 'rgba(200,210,255,0.35)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(tx - 4, tTop); ctx.lineTo(tx + tw + 4, tTop); ctx.stroke();
/* tube shine */
const shine = ctx.createLinearGradient(tx, 0, tx + tw * 0.35, 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);
ctx.lineTo(tx + tw * 0.3, tTop);
ctx.lineTo(tx + tw * 0.3, tBot - 15);
ctx.lineTo(tx + 3, tBot - 15);
ctx.closePath();
ctx.fill();
/* mode label */
ctx.font = '700 11px Manrope,sans-serif';
ctx.fillStyle = 'rgba(155,93,229,0.5)';
ctx.textAlign = 'center';
ctx.fillText(this._mode === 'identify' ? 'РЕЖИМ: ОПРЕДЕЛИТЬ ИОН' : 'РЕЖИМ: НЕИЗВЕСТНЫЙ РАСТВОР', W * 0.5, H - 8);
}
stop() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
}
}
/* ── 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();
}
}));
}