Files
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

2012 lines
106 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
return '';
});
}
/* ════════════════════════════════════════════════════════════════
ChemSandboxSim v2 — «Химическая песочница»
• Колба Эрленмейера с реалистичным стеклом
• Анимация вливания реагента сверху
• Удаление отдельного реагента из зоны
• Drag реагентов с полки
• Визуальные эффекты: осадки, газы, смена цвета, нагрев
════════════════════════════════════════════════════════════════ */
class ChemSandboxSim {
/* ── База веществ ─────────────────────────────────────────────── */
static SUBSTANCES = {
'HCl': { name: 'Соляная к-та', state: 'aq', color: '#78D278', cat: 'acid' },
'H2SO4': { name: 'Серная к-та', state: 'aq', color: '#D2C378', cat: 'acid' },
'HNO3': { name: 'Азотная к-та', state: 'aq', color: '#E8D060', cat: 'acid' },
'CH3COOH': { name: 'Уксусная к-та', state: 'aq', color: '#C8E0A0', cat: 'acid' },
'NaOH': { name: 'Гидроксид натрия', state: 'aq', color: '#7BF5A4', cat: 'base' },
'KOH': { name: 'Гидроксид калия', state: 'aq', color: '#7BF5A4', cat: 'base' },
'Ca(OH)2': { name: 'Гидроксид кальция', state: 'aq', color: '#E0E0E0', cat: 'base' },
'NH3·H2O': { name: 'Аммиачная вода', state: 'aq', color: '#A0D8F0', cat: 'base' },
'NaCl': { name: 'Хлорид натрия', state: 's', color: '#FFFFFF', cat: 'salt' },
'CuSO4': { name: 'Сульфат меди', state: 'aq', color: '#4CC9F0', cat: 'salt' },
'BaCl2': { name: 'Хлорид бария', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'AgNO3': { name: 'Нитрат серебра', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'Na2CO3': { name: 'Карбонат натрия', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'FeCl3': { name: 'Хлорид железа(III)', state: 'aq', color: '#D4A040', cat: 'salt' },
'Pb(NO3)2':{ name: 'Нитрат свинца', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'K2CrO4': { name: 'Хромат калия', state: 'aq', color: '#FFD700', cat: 'salt' },
'Zn': { name: 'Цинк', state: 's', color: '#9BB8CC', cat: 'metal' },
'Fe': { name: 'Железо', state: 's', color: '#A08060', cat: 'metal' },
'Cu': { name: 'Медь', state: 's', color: '#C87840', cat: 'metal' },
'Mg': { name: 'Магний', state: 's', color: '#D6D6D6', cat: 'metal' },
'Na': { name: 'Натрий', state: 's', color: '#F5F0C8', cat: 'metal' },
'H2O': { name: 'Вода', state: 'l', color: '#6EB4D7', cat: 'other' },
'Phenolphthalein': { name: 'Фенолфталеин', state: 'ind', color: '#E0E0E0', cat: 'indicator' },
'Litmus': { name: 'Лакмус', state: 'ind', color: '#9B59B6', cat: 'indicator' },
'MethylOrange': { name: 'Метилоранж', state: 'ind', color: '#FF8C00', cat: 'indicator' },
};
/* ── База реакций ─────────────────────────────────────────────── */
static REACTIONS = [
// ── Нейтрализация ──
{ r: ['HCl','NaOH'], eq: 'HCl + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaCl + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + Cl⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['H2SO4','NaOH'], eq: 'H₂SO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HNO3','NaOH'], eq: 'HNO₃ + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + NO₃⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HCl','KOH'], eq: 'HCl + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KCl + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + Cl⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HCl','Ca(OH)2'], eq: '2HCl + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCl₂ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + 2Cl⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Ca²⁺ + 2Cl⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['CH3COOH','NaOH'], eq: 'CH₃COOH + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COONa + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'CH₃COOH + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + Na⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
why: 'Слабая кислота реагирует с OH⁻ целиком (не диссоциирует полностью)' },
{ r: ['H2SO4','KOH'], eq: 'H₂SO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2K⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HNO3','KOH'], eq: 'HNO₃ + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + NO₃⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['CH3COOH','KOH'], eq: 'CH₃COOH + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COOK + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'CH₃COOH + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + K⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
why: 'Слабая кислота реагирует с OH⁻ целиком' },
{ r: ['H2SO4','Ca(OH)2'], eq: 'H₂SO₄ + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O', type: 'Нейтрализация', fx: { heat: true, precip: { c: '#F0F0F0', n: 'CaSO₄' } },
ionFull: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O', ionNet: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O',
why: 'Нейтрализация + образование малорастворимого CaSO₄' },
// ── Замещение (металл + кислота) ──
{ r: ['Zn','HCl'], eq: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Zn⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Zn','H2SO4'], eq: 'Zn + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Zn⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Fe','HCl'], eq: 'Fe + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Fe⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Fe','H2SO4'], eq: 'Fe + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Fe⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Mg','HCl'], eq: 'Mg + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true },
ionFull: 'Mg⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
{ r: ['Mg','H2SO4'], eq: 'Mg + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true },
ionFull: 'Mg⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
// ── Замещение (металл + соль) ──
{ r: ['CuSO4','Fe'], eq: 'CuSO₄ + Fe <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#90C090' },
ionFull: 'Cu²⁺ + SO₄²⁻ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
{ r: ['CuSO4','Zn'], eq: 'CuSO₄ + Zn <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#E0E0E0' },
ionFull: 'Cu²⁺ + SO₄²⁻ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Zn активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
{ r: ['AgNO3','Cu'], eq: '2AgNO₃ + Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(NO₃)₂ + 2Ag<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#C0C0C0', n: 'Ag' }, colorTo: '#4CC9F0' },
ionFull: '2Ag⁺ + 2NO₃⁻ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2NO₃⁻ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: '2Ag⁺ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu активнее Ag <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет серебро из раствора' },
// ── Обмен с осадком ──
{ r: ['AgNO3','NaCl'], eq: 'AgNO₃ + NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
ionFull: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['AgNO3','HCl'], eq: 'AgNO₃ + HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + HNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
ionFull: 'Ag⁺ + NO₃⁻ + H⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['BaCl2','H2SO4'], eq: 'BaCl₂ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2HCl', type: 'Обмен', fx: { precip: { c: '#FFFFFF', n: 'BaSO₄' } },
ionFull: 'Ba²⁺ + 2Cl⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H⁺ + 2Cl⁻', ionNet: 'Ba²⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'BaSO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['CuSO4','NaOH'], eq: 'CuSO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
ionFull: 'Cu²⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> осаждается голубой гидроксид' },
{ r: ['FeCl3','NaOH'], eq: 'FeCl₃ + 3NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3NaCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
ionFull: 'Fe³⁺ + 3Cl⁻ + 3Na⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3Na⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
{ r: ['Pb(NO3)2','K2CrO4'],eq: 'Pb(NO₃)₂ + K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',type:'Обмен', fx: { precip: { c: '#FFD700', n: 'PbCrO₄' } },
ionFull: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'PbCrO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> яркий жёлтый осадок' },
{ r: ['FeCl3','K2CrO4'], eq: '2FeCl₃ + 3K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6KCl',type:'Обмен', fx: { precip: { c: '#8B6914', n: 'Fe₂(CrO₄)₃' } },
ionFull: '2Fe³⁺ + 6Cl⁻ + 6K⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6K⁺ + 6Cl⁻', ionNet: '2Fe³⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe₂(CrO₄)₃ нерастворим' },
{ r: ['Pb(NO3)2','NaCl'], eq: 'Pb(NO₃)₂ + 2NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'PbCl₂' } },
ionFull: 'Pb²⁺ + 2NO₃⁻ + 2Na⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'PbCl₂ малорастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> белый осадок' },
{ r: ['CuSO4','KOH'], eq: 'CuSO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + K₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
ionFull: 'Cu²⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> голубой осадок' },
{ r: ['FeCl3','KOH'], eq: 'FeCl₃ + 3KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3KCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
ionFull: 'Fe³⁺ + 3Cl⁻ + 3K⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3K⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
// ── Обмен с газом ──
{ r: ['Na2CO3','HCl'], eq: 'Na₂CO₃ + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2Cl⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','H2SO4'], eq: 'Na₂CO₃ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','HNO3'], eq: 'Na₂CO₃ + 2HNO₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaNO₃ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2NO₃⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2NO₃⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','CH3COOH'], eq: 'Na₂CO₃ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COONa + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + 2Na⁺ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Уксусная кислота слабая, но карбонат-ион связывает H⁺' },
// ── Активный металл + вода ──
{ r: ['Na','H2O'], eq: '2Na + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaOH + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, violent: true },
ionNet: '2Na⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2OH⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Na — щелочной металл, бурно реагирует с водой' },
{ r: ['Mg','H2O'], eq: 'Mg + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, precip: { c: '#E0E0E0', n: 'Mg(OH)₂' } },
ionNet: 'Mg⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg реагирует с горячей водой, Mg(OH)₂ малорастворим' },
// ── Индикаторы ──
{ r: ['Phenolphthalein','NaOH'], eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','KOH'], eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','Ca(OH)2'],eq:'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','NH3·H2O'],eq:'Фенолфталеин + аммиак <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бл.-розовый', type: 'Индикатор', fx: { colorTo: '#FFB0C0' },
why: 'NH₃·H₂O — слабое основание, pH ~11, бледная окраска' },
{ r: ['Litmus','HCl'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','H2SO4'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','HNO3'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','NaOH'], eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий', type: 'Индикатор', fx: { colorTo: '#4466FF' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
{ r: ['Litmus','KOH'], eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий', type: 'Индикатор', fx: { colorTo: '#4466FF' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
{ r: ['MethylOrange','HCl'], eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' },
why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
{ r: ['MethylOrange','H2SO4'], eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' },
why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
{ r: ['MethylOrange','NaOH'], eq: 'Метилоранж + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> жёлтый', type: 'Индикатор', fx: { colorTo: '#FFD700' },
why: 'pH > 4.4 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж жёлтый' },
// ── Нет реакции ──
{ r: ['Cu','HCl'], eq: 'Cu + HCl — реакция не идёт', type: 'Нет реакции', fx: { none: true },
why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
{ r: ['Cu','H2SO4'], eq: 'Cu + H₂SO₄(разб.) — нет реакции',type: 'Нет реакции', fx: { none: true },
why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
];
/* ── Ряд активности металлов ─────────────────────────────────── */
static ACTIVITY_SERIES = [
{ sym: 'K', name: 'Калий' },
{ sym: 'Na', name: 'Натрий' },
{ sym: 'Ca', name: 'Кальций' },
{ sym: 'Mg', name: 'Магний' },
{ sym: 'Al', name: 'Алюминий' },
{ sym: 'Zn', name: 'Цинк' },
{ sym: 'Fe', name: 'Железо' },
{ sym: 'Ni', name: 'Никель' },
{ sym: 'Sn', name: 'Олово' },
{ sym: 'Pb', name: 'Свинец' },
{ sym: 'H₂', name: 'Водород' },
{ sym: 'Cu', name: 'Медь' },
{ sym: 'Ag', name: 'Серебро' },
{ sym: 'Au', name: 'Золото' },
];
static PRESETS = {
neutralization: ['HCl', 'NaOH'],
gas_evolution: ['Na2CO3', 'HCl'],
precipitate: ['AgNO3', 'NaCl'],
displacement: ['CuSO4', 'Fe'],
indicator: ['Phenolphthalein', 'NaOH'],
violent: ['Na', 'H2O'],
yellow_precip: ['Pb(NO3)2', 'K2CrO4'],
blue_precip: ['CuSO4', 'NaOH'],
};
/* ── Конструктор ─────────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.mixContents = [];
this.lastReaction = null;
this.filterCat = 'all';
// liquid state
this._liqColor = null;
this._liqTargetColor = null;
this._liqLevel = 0;
this._precipLevel = 0;
this._precipColor = null;
// particles
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._pourDrops = []; // pour animation
// animation
this._raf = null;
this._last = 0;
this._time = 0;
this._animPhase = 0;
this._reacTimer = 0;
// flask geometry cache
this._g = {};
// glow / waves
this._glowPulse = 0;
this._heatGlow = 0;
this._wave = 0;
this._wave2 = 0;
this._wave3 = 0;
// gas label
this._gasLabel = null;
// pour animation state
this._pouring = false;
this._pourColor = null;
this._pourTimer = 0;
// drag state
this._drag = null; // { formula, x, y }
// added items chips (for removal)
this._chipLayout = []; // [{formula, x, y, w, h}]
// shelf layout cache
this._shelfKeys = [];
this._shelfStartX = 0;
this._shelfBottleW = 0;
this._shelfGap = 0;
this._shelfBottleY = 0;
this._shelfBottleH = 0;
this._shelfLayout = []; // [{formula, x, y, w, h}]
this._shelfScroll = 0; // horizontal scroll offset
this._shelfHover = -1; // hovered card index
// pending preset timeouts
this._presetTimers = [];
// quiz mode
this._quizMode = false;
this._quizTask = null; // { rx, question, answer: [f1,f2] }
this._quizScore = 0;
this._quizTotal = 0;
this._quizResult = null; // 'correct' | 'wrong' | null
this._quizResultT = 0;
// edu-tooltip state
this._eduTooltipAge = -1; // -1 = inactive; 0..1 = active
this._eduTooltipLines = [];
this._showHints = true; // can be toggled
// product label animation state
this._prodLabelAge = -1;
this._prodLabelText = '';
this._prodLabelType = 'gas';
this.onUpdate = null;
this.onQuizUpdate = null; // callback(quizInfo)
this.fit();
}
/* ── Геометрия колбы Эрленмейера ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._calcGeom();
}
_calcGeom() {
const { W, H } = this;
// flask parameters (Erlenmeyer)
const r = Math.min(W * 0.17, H * 0.18); // body radius (smaller to fit info)
const cx = W * 0.50;
const cy = H * 0.34; // body center (higher)
const nw = r * 0.22; // neck half-width
const nh = r * 0.90; // neck height
const nt = cy - r - nh; // neck top Y
const nb = cy - r * 0.75; // neck-shoulder transition Y
const liqTop = cy - r * 0.35; // default liquid surface
// shelf — 2-row grid with large cards
const shelfH = 140;
const shelfY = H - shelfH - 4;
this._g = { r, cx, cy, nw, nh, nt, nb, liqTop, shelfY, shelfH };
}
_flaskPath(ctx) {
const { r, cx, cy, nw, nt, nb } = this._g;
ctx.beginPath();
ctx.moveTo(cx - nw, nt);
ctx.lineTo(cx - nw, nb);
ctx.bezierCurveTo(cx - nw, cy - r * 0.38, cx - r * 0.85, cy - r * 0.08, cx - r, cy);
ctx.arc(cx, cy, r, Math.PI, 0, true);
ctx.bezierCurveTo(cx + r * 0.85, cy - r * 0.08, cx + nw, cy - r * 0.38, cx + nw, nb);
ctx.lineTo(cx + nw, nt);
ctx.closePath();
}
/* ── Запуск / остановка ─────────────────────────────────────── */
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Публичный API ──────────────────────────────────────────── */
reset() {
// cancel any pending preset timers
for (const t of this._presetTimers) clearTimeout(t);
this._presetTimers = [];
this.mixContents = [];
this.lastReaction = null;
this._liqColor = null;
this._liqTargetColor = null;
this._liqLevel = 0;
this._precipLevel = 0;
this._precipColor = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._pourDrops = [];
this._animPhase = 0;
this._reacTimer = 0;
this._heatGlow = 0;
this._gasLabel = null;
this._pouring = false;
this._chipLayout = [];
this._fireInfo();
}
resetReaction() {
// keep reagents in the zone, but clear reaction effects
this.lastReaction = null;
this._liqTargetColor = null;
this._animPhase = 0;
this._reacTimer = 0;
this._gasLabel = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._precipLevel = 0;
this._precipColor = null;
this._heatGlow = 0;
// recalc liquid to original colors without reaction effects
this._recalcLiquid();
this._fireInfo();
}
addToMix(formula) {
if (this.mixContents.length >= 4) return;
if (this.mixContents.includes(formula)) return;
this.mixContents.push(formula);
const sub = ChemSandboxSim.SUBSTANCES[formula];
if (window.LabFX) {
LabFX.sound.play('pour');
const { cx, nt, nw } = this._g;
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: nt - 10, count: 15, color: sub.color,
speed: 60, spread: 1.8, angle: Math.PI / 2, gravity: 300, life: 800, shape: 'splash' });
}
// pour animation
this._pouring = true;
this._pourColor = sub.color;
this._pourTimer = 0;
this._spawnPourDrops(sub.color, sub.state === 's');
// liquid level (cap at 0.85 to keep within flask body)
if (sub.state !== 's') {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
this._liqColor = this._liqColor
? this._blendColors(this._liqColor, sub.color, 0.45)
: sub.color;
} else {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
}
// check reaction
this._checkReaction();
this._fireInfo();
}
removeFromMix(formula) {
const idx = this.mixContents.indexOf(formula);
if (idx === -1) return;
this.mixContents.splice(idx, 1);
// recalculate liquid
this._recalcLiquid();
// re-check reaction
this.lastReaction = null;
this._animPhase = 0;
this._gasLabel = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._precipLevel = 0;
this._precipColor = null;
this._heatGlow = 0;
this._liqTargetColor = null;
if (this.mixContents.length >= 2) this._checkReaction();
this._fireInfo();
}
_recalcLiquid() {
this._liqLevel = 0;
this._liqColor = null;
for (const f of this.mixContents) {
const s = ChemSandboxSim.SUBSTANCES[f];
if (s.state !== 's') {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
this._liqColor = this._liqColor
? this._blendColors(this._liqColor, s.color, 0.45)
: s.color;
} else {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
}
}
}
setCategory(cat) { this.filterCat = cat; }
preset(name) {
const p = ChemSandboxSim.PRESETS[name];
if (!p) return;
this.reset();
// add with staggered delay (store timer IDs so reset can cancel them)
this._presetTimers = p.map((f, i) =>
setTimeout(() => this.addToMix(f), i * 600)
);
}
info() {
const r = this.lastReaction;
return {
mixed: this.mixContents.length,
contents: [...this.mixContents],
reaction: r && !r.fx.none ? true : false,
type: r ? r.type : null,
equation: r ? r.eq : null,
products: r && !r.fx.none ? this._productsStr(r) : null,
ionFull: r ? r.ionFull || null : null,
ionNet: r ? r.ionNet || null : null,
why: r ? r.why || null : null,
};
}
/* ── Поиск реакции ─────────────────────────────────────────── */
_checkReaction() {
const c = this.mixContents;
if (c.length < 2) return;
for (let i = 0; i < c.length; i++) {
for (let j = i + 1; j < c.length; j++) {
const rx = this._findReaction(c[i], c[j]);
if (rx) {
this.lastReaction = rx;
this._triggerReaction(rx);
if (this._quizMode) this._checkQuizAnswer();
return;
}
}
}
}
_findReaction(a, b) {
return ChemSandboxSim.REACTIONS.find(rx =>
(rx.r[0] === a && rx.r[1] === b) || (rx.r[0] === b && rx.r[1] === a)
) || null;
}
_productsStr(rx) {
const parts = rx.eq.split('<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>');
return parts.length > 1 ? parts[1].trim() : '—';
}
/* ── Эффекты реакции ────────────────────────────────────────── */
_triggerReaction(rx) {
const fx = rx.fx;
if (fx.none) { this._fireInfo(); return; }
this._animPhase = 1;
this._reacTimer = 0;
if (fx.colorTo) this._liqTargetColor = fx.colorTo;
if (fx.gas) { this._gasLabel = fx.gas; this._spawnBubbles(fx.violent ? 45 : 22); }
if (fx.precip) { this._precipColor = fx.precip.c; this._spawnPrecipitate(28); }
if (fx.heat) {
this._heatGlow = 1.0;
this._spawnSteam(fx.violent ? 25 : 12);
if (fx.violent) this._spawnSparks(35);
}
/* product label animation */
if (fx.gas && window.ChemVisuals) {
this._prodLabelText = fx.gas + ' ';
this._prodLabelType = 'gas';
this._prodLabelAge = 0;
} else if (fx.precip && window.ChemVisuals) {
this._prodLabelText = (fx.precip.n || '') + ' ';
this._prodLabelType = 'precip';
this._prodLabelAge = 0;
}
/* educational tooltip */
if (rx.why && this._showHints && window.ChemVisuals) {
const typeLabel = rx.type ? 'Тип: ' + rx.type : '';
const why = rx.why.replace(/<[^>]+>/g, ''); /* strip SVG tags */
this._eduTooltipLines = [
typeLabel,
...why.split(' ').reduce((acc, w) => {
const last = acc[acc.length - 1];
if (last && (last + ' ' + w).length < 32) {
acc[acc.length - 1] = last + ' ' + w;
} else {
acc.push(w);
}
return acc;
}, []),
].filter(Boolean).slice(0, 4);
this._eduTooltipAge = 0;
}
if (window.LabFX) {
const { cx, cy } = this._g;
if (fx.violent) {
// Combustion-like: flame sparks + shake
LabFX.sound.play('fizz');
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 20, count: 30,
color: '#FFA500', speed: 90, spread: 2.5, angle: -Math.PI / 2,
gravity: -100, life: 600, shape: 'spark', glow: true });
LabFX.shake(this.canvas, { intensity: 3, durMs: 300 });
} else if (fx.gas) {
// Gas evolution: fizz + rising bubbles
LabFX.sound.play('fizz');
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy, count: 10,
color: '#FFFFFF', speed: 40, spread: 1.2, angle: -Math.PI / 2,
gravity: -80, life: 1200, shape: 'ring' });
} else if (fx.precip) {
// Precipitate: settling dust
LabFX.sound.play('fizz');
LabFX.particles.emit({ ctx: this.ctx, x: cx, y: cy - 10, count: 12,
color: fx.precip.c || '#888888', speed: 20, spread: 1.5, angle: Math.PI / 2,
gravity: 60, life: 1500, shape: 'dust' });
} else {
LabFX.sound.play('fizz');
}
}
this._fireInfo();
}
/* ── Частицы ────────────────────────────────────────────────── */
_spawnPourDrops(color, isSolid) {
const { cx, nt, nw } = this._g;
const n = isSolid ? 8 : 15;
for (let i = 0; i < n; i++) {
this._pourDrops.push({
x: cx + (Math.random() - 0.5) * nw * 1.5,
y: nt - 20 - Math.random() * 30,
r: isSolid ? 2 + Math.random() * 3 : 1.5 + Math.random() * 2,
vy: 1.5 + Math.random() * 3,
vx: (Math.random() - 0.5) * 0.8,
color,
life: 1.0,
solid: isSolid,
});
}
}
_spawnBubbles(n) {
const { cx, cy, r } = this._g;
for (let i = 0; i < n; i++) {
const angle = (Math.random() - 0.5) * 1.2;
const dist = Math.random() * r * 0.6;
this._bubbles.push({
x: cx + Math.sin(angle) * dist,
y: cy + r * 0.3 - Math.random() * 15,
r: 1.5 + Math.random() * 4.5,
vy: -(1.5 + Math.random() * 3),
vx: (Math.random() - 0.5) * 0.8,
life: 1.0,
delay: Math.random() * 2.5,
});
}
}
_spawnPrecipitate(n) {
const { cx, cy, r } = this._g;
for (let i = 0; i < n; i++) {
this._precipParts.push({
x: cx + (Math.random() - 0.5) * r * 1.2,
y: cy - r * 0.2 + Math.random() * r * 0.4,
r: 1.5 + Math.random() * 3,
vy: 0.3 + Math.random() * 0.9,
vx: (Math.random() - 0.5) * 0.3,
life: 1.0,
delay: Math.random() * 1.8,
});
}
}
_spawnSteam(n) {
const { cx, nt, nw } = this._g;
for (let i = 0; i < n; i++) {
this._steamParts.push({
x: cx + (Math.random() - 0.5) * nw * 2,
y: nt - 5 + Math.random() * 10,
r: 3 + Math.random() * 7,
vy: -(0.5 + Math.random() * 1.8),
vx: (Math.random() - 0.5) * 0.6,
life: 1.0,
delay: Math.random() * 2.5,
});
}
}
_spawnSparks(n) {
const { cx, nt, nw } = this._g;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 2 + Math.random() * 5;
this._sparkParts.push({
x: cx + (Math.random() - 0.5) * nw,
y: nt + 5,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 3,
life: 1.0,
delay: Math.random() * 1.0,
});
}
}
/* ── Тик ────────────────────────────────────────────────────── */
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
this._time += dt;
this._wave += dt * 1.7;
this._wave2 += dt * 2.3;
this._wave3 += dt * 0.88;
this._glowPulse += dt * 3.2;
if (window.LabFX) LabFX.particles.update(dt);
if (this._animPhase === 1) {
this._reacTimer += dt;
if (this._reacTimer > 5.0) this._animPhase = 2;
}
// color lerp
if (this._liqTargetColor && this._liqColor) {
this._liqColor = this._lerpColor(this._liqColor, this._liqTargetColor, dt * 1.0);
}
if (this._heatGlow > 0) this._heatGlow = Math.max(0, this._heatGlow - dt * 0.12);
// pour animation
if (this._pouring) {
this._pourTimer += dt;
if (this._pourTimer > 1.2) this._pouring = false;
}
// quiz result timer
if (this._quizResultT > 0) {
this._quizResultT -= dt;
if (this._quizResultT <= 0) {
this._quizResultT = 0;
if (this._quizResult === 'correct') this._nextQuizTask();
}
}
/* advance edu-tooltip age (total lifespan = 4s) */
if (this._eduTooltipAge >= 0) {
this._eduTooltipAge += dt / 4.0;
if (this._eduTooltipAge >= 1.0) this._eduTooltipAge = -1;
}
/* advance product label age (total lifespan = 3s) */
if (this._prodLabelAge >= 0) {
this._prodLabelAge += dt / 3.0;
if (this._prodLabelAge >= 1.0) this._prodLabelAge = -1;
}
this._updatePour(dt);
this._updateBubbles(dt);
this._updatePrecip(dt);
this._updateSteam(dt);
this._updateSparks(dt);
this.draw();
}
_updatePour(dt) {
const { cy, r } = this._g;
const surfY = this._getSurfaceY();
for (let i = this._pourDrops.length - 1; i >= 0; i--) {
const d = this._pourDrops[i];
d.vy += 8 * dt; // gravity
d.y += d.vy;
d.x += d.vx;
if (d.y > surfY) {
d.life -= dt * 3;
}
if (d.life <= 0 || d.y > cy + r) this._pourDrops.splice(i, 1);
}
}
_updateBubbles(dt) {
const surfY = this._getSurfaceY();
for (let i = this._bubbles.length - 1; i >= 0; i--) {
const b = this._bubbles[i];
if (b.delay > 0) { b.delay -= dt; continue; }
b.x += b.vx;
b.y += b.vy;
b.vx += (Math.random() - 0.5) * 0.4;
b.life -= dt * 0.22;
if (b.y < surfY - 8 || b.life <= 0) this._bubbles.splice(i, 1);
}
if (this._animPhase === 1 && this._gasLabel && Math.random() < 0.35) {
this._spawnBubbles(2);
}
}
_updatePrecip(dt) {
const { cy, r } = this._g;
const bottom = cy + r - 14;
for (let i = this._precipParts.length - 1; i >= 0; i--) {
const p = this._precipParts[i];
if (p.delay > 0) { p.delay -= dt; continue; }
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.99;
if (p.y >= bottom) {
p.y = bottom; p.vy = 0; p.vx = 0;
this._precipLevel = Math.min(1, this._precipLevel + 0.004);
p.life -= dt * 0.25;
}
if (p.life <= 0) this._precipParts.splice(i, 1);
}
}
_updateSteam(dt) {
for (let i = this._steamParts.length - 1; i >= 0; i--) {
const s = this._steamParts[i];
if (s.delay > 0) { s.delay -= dt; continue; }
s.x += s.vx; s.y += s.vy;
s.r += dt * 1.8; s.life -= dt * 0.35;
if (s.life <= 0) this._steamParts.splice(i, 1);
}
}
_updateSparks(dt) {
for (let i = this._sparkParts.length - 1; i >= 0; i--) {
const s = this._sparkParts[i];
if (s.delay > 0) { s.delay -= dt; continue; }
s.x += s.vx; s.y += s.vy;
s.vy += 4 * dt;
s.life -= dt * 0.7;
if (s.life <= 0) this._sparkParts.splice(i, 1);
}
}
_getSurfaceY() {
const { cy, r, nt, nb } = this._g;
if (this._liqLevel <= 0) return cy + r;
// total fillable height: from flask bottom (cy+r) up to just below neck-shoulder (nb+4)
const maxH = (cy + r) - (nb + 4);
const liqH = maxH * Math.min(1, this._liqLevel) * 0.75;
return cy + r - liqH;
}
/* ── Рендеринг ─────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
ctx.clearRect(0, 0, W, H);
this._drawBackground();
this._drawFlaskShadow();
this._drawLiquid();
this._drawPrecipitate();
this._drawBubbles();
this._drawPourDrops();
this._drawFlaskGlass();
this._drawSteam();
this._drawSparks();
this._drawChips();
this._drawEquation();
this._drawShelf();
this._drawDragGhost();
if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint();
if (this._quizMode) this._drawQuizOverlay();
if (window.LabFX) LabFX.particles.draw(ctx);
/* edu-tooltip overlay */
if (window.ChemVisuals && this._eduTooltipAge >= 0 && this._eduTooltipLines.length > 0) {
const { cx, nt } = this._g;
ChemVisuals.drawEduTooltip(ctx, cx, nt - 20, 200, this._eduTooltipLines, this._eduTooltipAge);
}
}
_drawBackground() {
const { ctx, W, H } = this;
const { cy, r } = this._g;
const bg = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.75);
bg.addColorStop(0, '#0c0c1a');
bg.addColorStop(1, '#050508');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
ctx.lineWidth = 0.5;
for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
/* desk surface behind flask */
if (window.ChemVisuals) {
const tableY = cy + r + 6;
ChemVisuals.drawDeskBackground(ctx, W, H, tableY);
}
}
_drawFlaskShadow() {
const { ctx } = this;
const { cx, cy, r } = this._g;
if (window.ChemVisuals) {
ChemVisuals.drawVesselShadow(ctx, cx, cy + r + 4, r);
} else {
// fallback
const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1);
sg.addColorStop(0, 'rgba(0,0,0,0.25)');
sg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = sg;
ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25);
}
}
_drawLiquid() {
if (this._liqLevel <= 0) return;
const { ctx } = this;
const g = this._g;
const surfY = this._getSurfaceY();
ctx.save();
this._flaskPath(ctx);
ctx.clip();
const col = this._liqColor || '#6EB4D7';
const liqGrad = ctx.createLinearGradient(0, surfY, 0, g.cy + g.r);
liqGrad.addColorStop(0, this._alphaColor(col, 0.45));
liqGrad.addColorStop(0.5, this._alphaColor(col, 0.60));
liqGrad.addColorStop(1, this._alphaColor(col, 0.75));
ctx.fillStyle = liqGrad;
// wavy surface
ctx.beginPath();
ctx.moveTo(g.cx - g.r - 5, g.cy + g.r + 5);
const amp = 2.0;
const left = g.cx - g.r - 5, right = g.cx + g.r + 5;
for (let px = left; px <= right; px += 2) {
const t = (px - left) / (right - left);
const wy = surfY + Math.sin(t * 7 + this._wave) * amp
+ Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
ctx.lineTo(px, wy);
}
ctx.lineTo(right, g.cy + g.r + 5);
ctx.closePath();
ctx.fill();
// meniscus highlight
ctx.beginPath();
for (let px = left; px <= right; px += 2) {
const t = (px - left) / (right - left);
const wy = surfY + Math.sin(t * 7 + this._wave) * amp
+ Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
if (px === left) ctx.moveTo(px, wy); else ctx.lineTo(px, wy);
}
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
// SSS (sub-surface scattering glow)
const ssg = ctx.createRadialGradient(g.cx, surfY + 20, 5, g.cx, surfY + 20, g.r * 0.8);
ssg.addColorStop(0, this._alphaColor(col, 0.12));
ssg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = ssg;
ctx.fillRect(left, surfY, right - left, g.r * 2);
ctx.restore();
}
_drawPrecipitate() {
const { ctx } = this;
const { cx, cy, r } = this._g;
if (this._precipLevel <= 0 && this._precipParts.length === 0) return;
// everything clipped to flask shape
ctx.save();
this._flaskPath(ctx);
ctx.clip();
// settled layer at flask bottom
if (this._precipLevel > 0 && this._precipColor) {
const layerH = r * 0.30 * this._precipLevel;
const layerY = cy + r - layerH;
const pg = ctx.createLinearGradient(0, layerY, 0, cy + r);
pg.addColorStop(0, this._alphaColor(this._precipColor, 0.35));
pg.addColorStop(1, this._alphaColor(this._precipColor, 0.65));
ctx.fillStyle = pg;
ctx.fillRect(cx - r - 2, layerY, r * 2 + 4, layerH);
}
// falling particles (also clipped)
for (const p of this._precipParts) {
if (p.delay > 0) continue;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = this._alphaColor(this._precipColor || '#FFF', p.life * 0.65);
ctx.fill();
}
ctx.restore();
/* precipitate product label with animated arrow */
if (window.ChemVisuals && this._prodLabelType === 'precip' && this._prodLabelAge >= 0) {
ChemVisuals.drawProductLabel(ctx, cx, cy + r + 2, this._prodLabelText, 'precip', this._prodLabelAge);
}
}
_drawBubbles() {
const { ctx } = this;
for (const b of this._bubbles) {
if (b.delay > 0) continue;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,255,255,${b.life * 0.35})`;
ctx.lineWidth = 0.7;
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.25, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${b.life * 0.3})`;
ctx.fill();
}
// gas label above neck
if (this._gasLabel && this._bubbles.length > 0) {
const { cx, nt } = this._g;
if (window.ChemVisuals && this._prodLabelAge >= 0) {
/* animated product label with arrow */
ChemVisuals.drawProductLabel(ctx, cx, nt - 10, this._prodLabelText, 'gas', this._prodLabelAge);
/* continuous bubble particles near neck */
ChemVisuals.animateGasBubbles(ctx, cx, nt - 6, 'rgba(200,235,255,0.8)', this._time);
} else {
ctx.save();
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'center';
ctx.fillText(this._gasLabel + ' ', cx, nt - 14);
ctx.restore();
}
}
}
_drawPourDrops() {
const { ctx } = this;
for (const d of this._pourDrops) {
ctx.beginPath();
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
ctx.fillStyle = this._alphaColor(d.color, d.life * 0.7);
ctx.fill();
}
}
_drawSteam() {
const { ctx } = this;
for (const s of this._steamParts) {
if (s.delay > 0) continue;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,200,220,${s.life * 0.13})`;
ctx.fill();
}
}
_drawSparks() {
const { ctx } = this;
for (const s of this._sparkParts) {
if (s.delay > 0) continue;
ctx.save();
ctx.beginPath();
ctx.arc(s.x, s.y, 2.2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,200,60,${s.life * 0.85})`;
ctx.shadowColor = '#FFD060';
ctx.shadowBlur = 7;
ctx.fill();
ctx.restore();
}
}
_drawFlaskGlass() {
const { ctx } = this;
const g = this._g;
// glass body
ctx.save();
this._flaskPath(ctx);
// glass fill
const gf = ctx.createLinearGradient(g.cx - g.r, g.nt, g.cx + g.r, g.cy + g.r);
gf.addColorStop(0, 'rgba(255,255,255,0.05)');
gf.addColorStop(0.4, 'rgba(255,255,255,0.07)');
gf.addColorStop(1, 'rgba(255,255,255,0.03)');
ctx.fillStyle = gf;
ctx.fill();
// glass outline
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1.8;
ctx.stroke();
// heat glow
if (this._heatGlow > 0) {
ctx.shadowColor = `rgba(255,80,20,${this._heatGlow * 0.45})`;
ctx.shadowBlur = 30;
ctx.strokeStyle = `rgba(255,120,60,${this._heatGlow * 0.35})`;
ctx.stroke();
ctx.shadowBlur = 0;
}
ctx.restore();
// specular highlight — left edge
ctx.save();
ctx.beginPath();
ctx.moveTo(g.cx - g.nw + 2, g.nt + 6);
ctx.lineTo(g.cx - g.nw + 2, g.nb + 5);
ctx.bezierCurveTo(
g.cx - g.nw + 2, g.cy - g.r * 0.3,
g.cx - g.r * 0.75, g.cy - g.r * 0.05,
g.cx - g.r + 5, g.cy + g.r * 0.3
);
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.restore();
// neck rim
ctx.save();
ctx.beginPath();
ctx.ellipse(g.cx, g.nt, g.nw + 1, 3, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// graduated marks on neck
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 0.5;
for (let i = 1; i <= 3; i++) {
const my = g.nt + (g.nb - g.nt) * i / 4;
ctx.beginPath();
ctx.moveTo(g.cx - g.nw + 3, my);
ctx.lineTo(g.cx - g.nw + 10, my);
ctx.stroke();
}
ctx.restore();
}
/* ── Чипы добавленных реагентов (с кнопкой удаления) ──────── */
_drawChips() {
if (this.mixContents.length === 0) return;
const { ctx, W } = this;
const { nt } = this._g;
this._chipLayout = [];
const chipH = 22, gap = 6;
let totalW = 0;
const labels = this.mixContents.map(f => {
const lbl = this._shortFormula(f);
ctx.font = 'bold 10px "JetBrains Mono", monospace';
const w = ctx.measureText(lbl).width + 28; // padding + "×" button
totalW += w + gap;
return { formula: f, label: lbl, w };
});
totalW -= gap;
let x = (W - totalW) / 2;
const y = Math.max(6, nt - 32);
for (const { formula, label, w } of labels) {
const sub = ChemSandboxSim.SUBSTANCES[formula];
// chip bg
ctx.save();
ctx.beginPath();
this._roundRect(ctx, x, y, w, chipH, 6);
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fill();
ctx.strokeStyle = this._alphaColor(sub.color, 0.4);
ctx.lineWidth = 1;
ctx.stroke();
// color dot
ctx.beginPath();
ctx.arc(x + 10, y + chipH / 2, 4, 0, Math.PI * 2);
ctx.fillStyle = sub.color;
ctx.fill();
// label
ctx.font = 'bold 10px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText(label, x + 18, y + chipH / 2 + 3.5);
// "×" remove button
const xBtnX = x + w - 14;
ctx.font = 'bold 11px sans-serif';
ctx.fillStyle = 'rgba(255,100,100,0.6)';
ctx.textAlign = 'center';
ctx.fillText('×', xBtnX, y + chipH / 2 + 4);
ctx.restore();
this._chipLayout.push({ formula, x, y, w, h: chipH, xBtnX });
x += w + gap;
}
}
_drawEquation() {
if (!this.lastReaction) return;
const { ctx, W } = this;
const { cy, r } = this._g;
const rx = this.lastReaction;
let y = cy + r + 14;
ctx.save();
ctx.textAlign = 'center';
// ── Молекулярное уравнение ──
ctx.font = 'bold 17px "JetBrains Mono", monospace';
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
ctx.fillText(_csClean(rx.eq), W / 2, y);
y += 22;
// ── Тип реакции + пояснение ──
const tp = rx.type;
const tpColor = tp === 'Нет реакции' ? '#EF476F'
: tp === 'Индикатор' ? '#9B59B6'
: tp === 'Нейтрализация' ? '#FFD166'
: tp === 'Замещение' ? '#06D6E0'
: tp === 'Обмен' ? '#7BF5A4'
: tp === 'Акт. металл' ? '#EF476F' : '#aaa';
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = tpColor;
ctx.fillText(tp, W / 2, y);
y += 18;
// why explanation
if (rx.why) {
ctx.font = '13px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(_csClean(rx.why), W / 2, y);
y += 17;
}
// ── Полное ионное уравнение ──
if (rx.ionFull) {
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(155,200,255,0.60)';
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
y += 16;
}
// ── Сокращённое ионное уравнение ──
if (rx.ionNet) {
ctx.font = 'bold 13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(123,245,164,0.75)';
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
y += 16;
}
ctx.restore();
// ── Мини ряд активности (для замещения) ──
if (rx.type === 'Замещение' || rx.type === 'Нет реакции' || rx.type === 'Акт. металл') {
this._drawActivitySeries(y + 2);
}
}
_drawActivitySeries(topY) {
const { ctx, W } = this;
const series = ChemSandboxSim.ACTIVITY_SERIES;
const rx = this.lastReaction;
if (!rx) return;
// find which metals are involved
const involved = new Set();
for (const f of rx.r) {
for (const m of series) {
if (f === m.sym || f === m.sym.replace('₂', '2')) involved.add(m.sym);
}
}
const metalMap = { 'Zn': 'Zn', 'Fe': 'Fe', 'Cu': 'Cu', 'Mg': 'Mg', 'Na': 'Na', 'Ag': 'Ag' };
for (const f of rx.r) {
if (metalMap[f]) involved.add(f);
}
for (const f of rx.r) {
if (f.startsWith('Cu')) involved.add('Cu');
if (f.startsWith('Ag')) involved.add('Ag');
if (f.startsWith('Fe') && !f.includes('Cl')) involved.add('Fe');
}
if (involved.size === 0) return;
const cellW = 28, cellH = 18, gap = 2;
const totalW = series.length * (cellW + gap) - gap;
const startX = (W - totalW) / 2;
const y = topY;
ctx.save();
ctx.font = '8px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.textAlign = 'center';
ctx.fillText('Ряд активности металлов', W / 2, y);
const rowY = y + 5;
for (let i = 0; i < series.length; i++) {
const m = series[i];
const x = startX + i * (cellW + gap);
const isInvolved = involved.has(m.sym) || involved.has(m.sym.replace('₂', '2'));
const isH = m.sym === 'H₂';
ctx.beginPath();
this._roundRect(ctx, x, rowY, cellW, cellH, 3);
ctx.fillStyle = isInvolved ? 'rgba(6,214,224,0.15)' : isH ? 'rgba(255,200,60,0.08)' : 'rgba(255,255,255,0.03)';
ctx.fill();
ctx.strokeStyle = isInvolved ? 'rgba(6,214,224,0.45)' : isH ? 'rgba(255,200,60,0.20)' : 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.7;
ctx.stroke();
ctx.font = isInvolved ? 'bold 9px "JetBrains Mono", monospace' : '8px "JetBrains Mono", monospace';
ctx.fillStyle = isInvolved ? '#06D6E0' : isH ? '#FFD166' : 'rgba(255,255,255,0.35)';
ctx.textAlign = 'center';
ctx.fillText(m.sym, x + cellW / 2, rowY + cellH / 2 + 3);
}
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '9px sans-serif';
ctx.fillText('← активнее', startX + 40, rowY + cellH + 10);
ctx.fillText('менее активные →', startX + totalW - 60, rowY + cellH + 10);
ctx.restore();
}
_drawShelf() {
const { ctx, W } = this;
const { shelfY, shelfH } = this._g;
if (shelfH < 30) return;
// shelf background
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.012)';
ctx.fillRect(0, shelfY - 4, W, shelfH + 8);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(0, shelfY - 4); ctx.lineTo(W, shelfY - 4); ctx.stroke();
ctx.restore();
const subs = ChemSandboxSim.SUBSTANCES;
const keys = Object.keys(subs).filter(k =>
this.filterCat === 'all' || subs[k].cat === this.filterCat
);
// 2-row grid layout
const rows = 2;
const bW = 68, bH = 60, gapX = 7, gapY = 6;
const cols = Math.ceil(keys.length / rows);
const totalW = cols * (bW + gapX) - gapX;
const padX = 12;
const visW = W - padX * 2;
// clamp scroll
const maxScroll = Math.max(0, totalW - visW);
this._shelfScroll = Math.max(0, Math.min(maxScroll, this._shelfScroll));
const scrollOff = -this._shelfScroll;
const gridTop = shelfY + Math.max(2, (shelfH - (bH * rows + gapY * (rows - 1))) / 2);
// clip to shelf region
ctx.save();
ctx.beginPath();
ctx.rect(padX - 2, shelfY - 4, visW + 4, shelfH + 8);
ctx.clip();
// store layout for click detection
const layoutArr = [];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const s = subs[k];
const col = Math.floor(i / rows);
const row = i % rows;
const x = padX + col * (bW + gapX) + scrollOff;
const y = gridTop + row * (bH + gapY);
// skip if off-screen
if (x + bW < padX - 10 || x > W - padX + 10) {
layoutArr.push({ formula: k, x, y, w: bW, h: bH });
continue;
}
const inMix = this.mixContents.includes(k);
const isHov = this._shelfHover === i;
// card background
ctx.beginPath();
this._roundRect(ctx, x, y, bW, bH, 8);
const bgAlpha = inMix ? 0.16 : isHov ? 0.07 : 0.035;
ctx.fillStyle = inMix
? `rgba(75,205,155,${bgAlpha})`
: `rgba(255,255,255,${bgAlpha})`;
ctx.fill();
ctx.strokeStyle = inMix
? 'rgba(75,205,155,0.55)'
: isHov ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.07)';
ctx.lineWidth = inMix ? 1.6 : 1;
ctx.stroke();
// color swatch (rounded rect, not dot)
const swX = x + 8, swY = y + 7, swW = bW - 16, swH = 14;
ctx.beginPath();
this._roundRect(ctx, swX, swY, swW, swH, 4);
ctx.fillStyle = this._alphaColor(s.color, inMix ? 0.55 : 0.35);
ctx.fill();
// formula (main label)
ctx.font = 'bold 12px "JetBrains Mono", monospace';
ctx.fillStyle = inMix ? '#7BF5A4' : 'rgba(255,255,255,0.75)';
ctx.textAlign = 'center';
ctx.fillText(this._shortFormula(k), x + bW / 2, y + 37);
// name
ctx.font = '8.5px "Manrope", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.30)';
ctx.fillText(s.name.substring(0, 13), x + bW / 2, y + 50);
// in-mix badge
if (inMix) {
ctx.beginPath();
ctx.arc(x + bW - 9, y + 9, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(75,205,155,0.65)';
ctx.fill();
ctx.font = 'bold 9px sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText('✓', x + bW - 9, y + 12.5);
}
layoutArr.push({ formula: k, x, y, w: bW, h: bH });
}
ctx.restore();
// scroll arrows if content overflows
if (maxScroll > 0) {
ctx.save();
const arrowY = shelfY + shelfH / 2;
// left arrow
if (this._shelfScroll > 0) {
ctx.beginPath();
ctx.moveTo(padX - 1, arrowY);
ctx.lineTo(padX + 7, arrowY - 8);
ctx.lineTo(padX + 7, arrowY + 8);
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
// right arrow
if (this._shelfScroll < maxScroll) {
const rx = W - padX + 1;
ctx.beginPath();
ctx.moveTo(rx, arrowY);
ctx.lineTo(rx - 8, arrowY - 8);
ctx.lineTo(rx - 8, arrowY + 8);
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
ctx.restore();
}
this._shelfKeys = keys;
this._shelfLayout = layoutArr;
this._shelfStartX = padX;
this._shelfBottleW = bW;
this._shelfGap = gapX;
this._shelfBottleY = gridTop;
this._shelfBottleH = bH;
}
_drawDragGhost() {
if (!this._drag) return;
const { ctx } = this;
const { formula, x, y } = this._drag;
const sub = ChemSandboxSim.SUBSTANCES[formula];
if (!sub) return;
ctx.save();
ctx.globalAlpha = 0.7;
// small bottle ghost
ctx.beginPath();
this._roundRect(ctx, x - 18, y - 22, 36, 44, 5);
ctx.fillStyle = 'rgba(75,205,155,0.15)';
ctx.fill();
ctx.strokeStyle = 'rgba(75,205,155,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y - 8, 5, 0, Math.PI * 2);
ctx.fillStyle = sub.color;
ctx.fill();
ctx.font = '9px "JetBrains Mono", monospace';
ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText(this._shortFormula(formula), x, y + 10);
ctx.globalAlpha = 1;
ctx.restore();
}
_drawHint() {
const { ctx, W } = this;
const { cy } = this._g;
ctx.save();
ctx.textAlign = 'center';
ctx.font = '14px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillText('Выберите реагенты на полке или панели', W / 2, cy);
/* small test-tube icon (canvas-drawn, no emoji) */
if (window.ChemVisuals) {
ChemVisuals.drawTube(ctx, W / 2, cy - 52, 36, null);
}
ctx.restore();
}
_drawQuizOverlay() {
if (!this._quizTask) return;
const { ctx, W } = this;
const { nt } = this._g;
ctx.save();
ctx.textAlign = 'center';
// question banner at top
const bannerY = Math.max(4, nt - 58);
ctx.fillStyle = 'rgba(155,93,229,0.12)';
ctx.beginPath();
this._roundRect(ctx, W / 2 - 180, bannerY, 360, 24, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.30)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.font = 'bold 11px "Manrope", sans-serif';
ctx.fillStyle = '#C9A0FF';
ctx.fillText(this._quizTask.question, W / 2, bannerY + 15);
// score
ctx.font = '9px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'right';
ctx.fillText(`${this._quizScore}/${this._quizTotal}`, W - 12, bannerY + 14);
// result flash
if (this._quizResult && this._quizResultT > 0) {
const alpha = Math.min(1, this._quizResultT / 0.5);
const isOk = this._quizResult === 'correct';
ctx.textAlign = 'center';
ctx.font = 'bold 18px "Manrope", sans-serif';
ctx.fillStyle = isOk
? `rgba(123,245,164,${alpha * 0.9})`
: `rgba(239,71,111,${alpha * 0.9})`;
ctx.fillText(isOk ? 'Верно!' : 'Неверно', W / 2, bannerY + 50);
if (!isOk && this._quizTask) {
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65);
}
}
ctx.restore();
}
/* ── Мышь ──────────────────────────────────────────────────── */
_hitShelfCard(mx, my) {
if (!this._shelfLayout) return null;
for (let i = 0; i < this._shelfLayout.length; i++) {
const c = this._shelfLayout[i];
if (mx >= c.x && mx <= c.x + c.w && my >= c.y && my <= c.y + c.h) return i;
}
return null;
}
handleClick(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// check chip "×" buttons first
for (const chip of this._chipLayout) {
if (mx >= chip.xBtnX - 8 && mx <= chip.xBtnX + 8 && my >= chip.y && my <= chip.y + chip.h) {
this.removeFromMix(chip.formula);
return;
}
}
// check shelf cards (2-row grid)
const idx = this._hitShelfCard(mx, my);
if (idx !== null && this._shelfLayout[idx]) {
this.addToMix(this._shelfLayout[idx].formula);
return;
}
}
handleMouseDown(e) {
if (e.button !== 0) return;
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const idx = this._hitShelfCard(mx, my);
if (idx !== null && this._shelfLayout[idx]) {
this._drag = { formula: this._shelfLayout[idx].formula, x: mx, y: my, startX: mx, startY: my };
}
}
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (this._drag) {
this._drag.x = mx;
this._drag.y = my;
return;
}
// hover detection
const idx = this._hitShelfCard(mx, my);
this._shelfHover = idx !== null ? idx : -1;
}
handleMouseUp(e) {
if (!this._drag) return;
const d = this._drag;
this._drag = null;
// if dragged into flask area — add
const { cx, cy, r, nt } = this._g;
const dx = d.x - cx, dy = d.y - cy;
const inFlask = (dy > nt - cy - 20) && (dx * dx + dy * dy < (r + 30) * (r + 30));
const movedEnough = Math.abs(d.x - d.startX) + Math.abs(d.y - d.startY) > 10;
if (inFlask && movedEnough) {
this.addToMix(d.formula);
}
}
handleWheel(e) {
const rect = this.canvas.getBoundingClientRect();
const my = e.clientY - rect.top;
const { shelfY, shelfH } = this._g;
// only scroll when cursor is over shelf area
if (my >= shelfY - 10 && my <= shelfY + shelfH + 10) {
e.preventDefault();
this._shelfScroll += e.deltaY * 0.8;
}
}
handleContextMenu(e) {
e.preventDefault();
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 });
this.reset();
}
/* ── Режим заданий (квиз) ──────────────────────────────────── */
startQuiz() {
this._quizMode = true;
this._quizScore = 0;
this._quizTotal = 0;
this.filterCat = 'all'; // show all reagents so quiz tasks are always solvable
this._nextQuizTask();
this._fireQuizInfo();
}
stopQuiz() {
this._quizMode = false;
this._quizTask = null;
this._quizResult = null;
this.reset();
this._fireQuizInfo();
}
_nextQuizTask() {
this.reset();
// pick a random non-indicator, non-"no reaction" reaction
const pool = ChemSandboxSim.REACTIONS.filter(rx =>
rx.type !== 'Индикатор' && rx.type !== 'Нет реакции'
);
const rx = pool[Math.floor(Math.random() * pool.length)];
const questions = this._generateQuestions(rx);
const q = questions[Math.floor(Math.random() * questions.length)];
this._quizTask = { rx, question: q, answer: [...rx.r] };
this._quizResult = null;
this._fireQuizInfo();
}
_generateQuestions(rx) {
const prods = this._productsStr(rx);
const questions = [];
if (rx.fx.precip) {
questions.push(`Получи осадок ${rx.fx.precip.n}`);
}
if (rx.fx.gas) {
questions.push(`Получи газ ${rx.fx.gas}`);
}
questions.push(`Проведи реакцию: ${_csClean(prods)}`);
if (rx.type === 'Нейтрализация') {
questions.push('Проведи реакцию нейтрализации');
}
if (rx.type === 'Замещение') {
questions.push('Проведи реакцию замещения');
}
return questions;
}
_checkQuizAnswer() {
if (!this._quizMode || !this._quizTask) return;
const needed = this._quizTask.answer;
const has = this.mixContents;
if (has.length < 2) return;
// check if the correct reagents are present
const correct = needed.every(f => has.includes(f));
this._quizTotal++;
if (correct) {
this._quizScore++;
this._quizResult = 'correct';
} else {
this._quizResult = 'wrong';
}
this._quizResultT = 2.5; // seconds to show result
this._fireQuizInfo();
}
_fireQuizInfo() {
if (this.onQuizUpdate) {
this.onQuizUpdate({
active: this._quizMode,
question: this._quizTask ? this._quizTask.question : null,
score: this._quizScore,
total: this._quizTotal,
result: this._quizResult,
answer: this._quizTask ? _csClean(this._quizTask.rx.eq) : null,
});
}
}
/* ── Утилиты ───────────────────────────────────────────────── */
_roundRect(ctx, x, y, w, h, rad) {
ctx.moveTo(x + rad, y);
ctx.lineTo(x + w - rad, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + rad);
ctx.lineTo(x + w, y + h - rad);
ctx.quadraticCurveTo(x + w, y + h, x + w - rad, y + h);
ctx.lineTo(x + rad, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rad);
ctx.lineTo(x, y + rad);
ctx.quadraticCurveTo(x, y, x + rad, y);
}
_alphaColor(hex, alpha) {
if (!hex || !hex.startsWith('#')) return `rgba(128,128,128,${alpha})`;
const rv = parseInt(hex.slice(1, 3), 16) || 0;
const gv = parseInt(hex.slice(3, 5), 16) || 0;
const bv = parseInt(hex.slice(5, 7), 16) || 0;
return `rgba(${rv},${gv},${bv},${alpha})`;
}
_blendColors(c1, c2, t) {
const h = (s) => s.startsWith('#') ? s.slice(1) : '888888';
const p = (s, o) => parseInt(s.slice(o, o + 2), 16) || 0;
const h1 = h(c1), h2 = h(c2);
const rv = Math.round(p(h1, 0) * (1 - t) + p(h2, 0) * t);
const gv = Math.round(p(h1, 2) * (1 - t) + p(h2, 2) * t);
const bv = Math.round(p(h1, 4) * (1 - t) + p(h2, 4) * t);
return '#' + [rv, gv, bv].map(v => v.toString(16).padStart(2, '0')).join('');
}
_lerpColor(from, to, t) {
const safe = c => (c && c.startsWith('#')) ? c : '#6EB4D7';
return this._blendColors(safe(from), safe(to), Math.min(1, t));
}
_shortFormula(key) {
const map = {
'CH3COOH': 'CH₃COOH', 'Ca(OH)2': 'Ca(OH)₂', 'NH3·H2O': 'NH₃·H₂O',
'H2SO4': 'H₂SO₄', 'HNO3': 'HNO₃', 'Na2CO3': 'Na₂CO₃',
'CuSO4': 'CuSO₄', 'BaCl2': 'BaCl₂', 'AgNO3': 'AgNO₃',
'FeCl3': 'FeCl₃', 'Pb(NO3)2': 'Pb(NO₃)₂', 'K2CrO4': 'K₂CrO₄',
'H2O': 'H₂O', 'Phenolphthalein': 'ФФт', 'Litmus': 'Лакм.',
'MethylOrange': 'МетОр.',
};
return map[key] || key;
}
_fireInfo() {
if (this.onUpdate) this.onUpdate(this.info());
}
}
/* ─── lab UI init ─────────────────────────────────── */
function _openChemSandbox() {
document.getElementById('sim-topbar-title').textContent = 'Химическая песочница';
_simShow('sim-chemsandbox');
_simShow('ctrl-chemsandbox');
requestAnimationFrame(() => requestAnimationFrame(() => {
const c = document.getElementById('chemsandbox-canvas');
if (!chemSandSim) {
chemSandSim = new ChemSandboxSim(c);
chemSandSim.onUpdate = _chemSandUpdateUI;
chemSandSim.onQuizUpdate = _chemSandQuizUI;
c.addEventListener('click', e => chemSandSim.handleClick(e));
c.addEventListener('mousedown', e => chemSandSim.handleMouseDown(e));
c.addEventListener('mousemove', e => chemSandSim.handleMouseMove(e));
c.addEventListener('mouseup', e => chemSandSim.handleMouseUp(e));
c.addEventListener('wheel', e => chemSandSim.handleWheel(e), { passive: false });
c.addEventListener('contextmenu', e => chemSandSim.handleContextMenu(e));
_addTouchSupport(c, chemSandSim);
_chemSandBuildReagents('all');
}
chemSandSim.fit();
chemSandSim.start();
chemSandSim.draw();
}));
}
function chemSandCat(cat, el) {
document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
el.classList.add('active');
if (chemSandSim) chemSandSim.setCategory(cat);
_chemSandBuildReagents(cat);
if (chemSandSim) chemSandSim.draw();
}
function chemSandPreset(name) { if (chemSandSim) { chemSandSim.preset(name); _chemSandBuildReagents(chemSandSim.filterCat); } }
function chemSandReset() { if (chemSandSim) { if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 0.7, volume: 0.3 }); chemSandSim.reset(); _chemSandBuildReagents(chemSandSim.filterCat); } }
function chemSandResetReaction() { if (chemSandSim) { chemSandSim.resetReaction(); _chemSandBuildReagents(chemSandSim.filterCat); } }
function chemSandConcChange() {
const v = +document.getElementById('sl-csand-conc').value;
document.getElementById('csand-conc-val').textContent = v + '%';
}
function chemSandTempChange() {
const v = +document.getElementById('sl-csand-temp').value;
document.getElementById('csand-temp-val').textContent = v + '°C';
}
function chemSandAdd(formula) {
if (!chemSandSim) return;
// toggle: if already in mix — remove, else add
if (chemSandSim.mixContents.includes(formula)) {
chemSandSim.removeFromMix(formula);
} else {
if (window.LabFX) LabFX.sound.play('click', { pitch: 1.2 });
chemSandSim.addToMix(formula);
}
_chemSandBuildReagents(chemSandSim.filterCat);
}
function _chemSandBuildReagents(cat) {
const box = document.getElementById('chemsand-reagents');
if (!box) return;
const subs = ChemSandboxSim.SUBSTANCES;
const keys = Object.keys(subs).filter(k => cat === 'all' || subs[k].cat === cat);
const inMix = chemSandSim ? chemSandSim.mixContents : [];
box.innerHTML = keys.map(k => {
const s = subs[k];
const active = inMix.includes(k);
const cls = active ? 'proj-preset-chip reac-mode-btn active' : 'proj-preset-chip reac-mode-btn';
const sf = chemSandSim ? chemSandSim._shortFormula(k) : k;
const removeHint = active ? ' (клик — убрать)' : '';
return `<button class="${cls}" onclick="chemSandAdd('${k}')" title="${s.name}${removeHint}" style="font-size:.68rem;padding:4px 7px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.color};margin-right:3px;vertical-align:middle"></span>${sf}${active ? ' ×' : ''}</button>`;
}).join('');
}
function chemSandSetMode(mode, el) {
document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
if (el) el.classList.add('active');
if (!chemSandSim) return;
if (mode === 'quiz') {
if (window._simQuizAllowed === false) {
LS.toast('Режим заданий недоступен — администратор ограничил доступ', 'error');
// revert button state
document.querySelectorAll('.chemsand-mode').forEach(b => b.classList.remove('active'));
document.getElementById('csand-mode-free')?.classList.add('active');
return;
}
chemSandSim.startQuiz();
// reset category filter to 'all' so all reagents are accessible
document.querySelectorAll('.chemsand-cat').forEach(b => b.classList.remove('active'));
const allBtn = document.querySelector('.chemsand-cat');
if (allBtn) allBtn.classList.add('active');
_chemSandBuildReagents('all');
} else {
chemSandSim.stopQuiz();
document.getElementById('csand-quiz-question').style.display = 'none';
document.getElementById('csand-quiz-result').style.display = 'none';
document.getElementById('csand-quiz-next').style.display = 'none';
document.getElementById('csand-quiz-score').textContent = '';
}
}
function chemSandQuizNext() {
if (chemSandSim && chemSandSim._quizMode) {
chemSandSim._nextQuizTask();
_chemSandBuildReagents(chemSandSim.filterCat);
}
}
function _chemSandQuizUI(qi) {
const qEl = document.getElementById('csand-quiz-question');
const rEl = document.getElementById('csand-quiz-result');
const nEl = document.getElementById('csand-quiz-next');
const sEl = document.getElementById('csand-quiz-score');
if (!qi.active) {
qEl.style.display = 'none'; rEl.style.display = 'none'; nEl.style.display = 'none';
sEl.textContent = '';
return;
}
qEl.style.display = 'block';
qEl.textContent = qi.question || '';
sEl.textContent = qi.total > 0 ? `${qi.score}/${qi.total}` : '';
if (qi.result) {
rEl.style.display = 'block';
rEl.style.color = qi.result === 'correct' ? '#7BF5A4' : '#EF476F';
rEl.textContent = qi.result === 'correct' ? 'Верно!' : 'Неверно — ' + (qi.answer || '');
nEl.style.display = qi.result === 'wrong' ? 'inline-block' : 'none';
} else {
rEl.style.display = 'none'; nEl.style.display = 'none';
}
}
let _lastReportedEquation = null;
function _chemSandUpdateUI(info) {
document.getElementById('csbar-v1').textContent = info.mixed;
document.getElementById('csbar-v3').textContent = info.type || '—';
const eqEl = document.getElementById('csbar-v4');
eqEl.innerHTML = info.equation || '—';
eqEl.title = (info.equation || '').replace(/<[^>]*>/g, '');
const prodEl = document.getElementById('csbar-v5');
prodEl.innerHTML = info.products || '—';
prodEl.title = (info.products || '').replace(/<[^>]*>/g, '');
const ionEl = document.getElementById('csbar-v6');
ionEl.innerHTML = info.ionNet || '—';
ionEl.title = (info.ionNet || '').replace(/<[^>]*>/g, '');
// rebuild reagent buttons to reflect active state
_chemSandBuildReagents(chemSandSim ? chemSandSim.filterCat : 'all');
// Report lab activity for gamification (once per unique reaction)
if (info.reaction && info.equation && info.equation !== _lastReportedEquation) {
_lastReportedEquation = info.equation;
if (window.LS?.reportLabActivity) LS.reportLabActivity(1).catch(() => {});
}
// ── HTML overlay: 3-form equation display ──
_chemSandShowEqOverlay(info);
}
/* equation overlay timer handle */
let _csEqOverlayTimer = null;
function _chemSandShowEqOverlay(info) {
const overlay = document.getElementById('chemsand-eq-overlay');
if (!overlay) return;
// clear any existing hide timer
if (_csEqOverlayTimer) { clearTimeout(_csEqOverlayTimer); _csEqOverlayTimer = null; }
if (!info.reaction || !info.equation) {
overlay.classList.remove('visible');
return;
}
/* helpers: strip SVG arrow markup → plain text "=" */
function _stripSvg(s) {
if (!s) return '';
return s.replace(/<svg[^>]*class="ic"[^>]*>[\s\S]*?<\/svg>/g, '=');
}
const molLine = document.getElementById('chemsand-eq-mol');
const fullLine = document.getElementById('chemsand-eq-full');
const netLine = document.getElementById('chemsand-eq-net');
const typeBadge = document.getElementById('chemsand-eq-type');
molLine.innerHTML = _stripSvg(info.equation);
fullLine.innerHTML = info.ionFull ? _stripSvg(info.ionFull) : '<span style="opacity:.45">ионное уравнение недоступно</span>';
netLine.innerHTML = info.ionNet ? _stripSvg(info.ionNet) : '<span style="opacity:.45">сокращённое ионное недоступно</span>';
const tpColor = info.type === 'Нет реакции' ? '#EF476F'
: info.type === 'Индикатор' ? '#9B59B6'
: info.type === 'Нейтрализация'? '#FFD166'
: info.type === 'Замещение' ? '#06D6E0'
: info.type === 'Обмен' ? '#7BF5A4'
: info.type === 'Акт. металл' ? '#EF476F'
: 'rgba(255,255,255,.5)';
typeBadge.textContent = info.type || '';
typeBadge.style.color = tpColor;
typeBadge.style.borderColor = tpColor + '55';
overlay.classList.add('visible');
/* auto-hide after 5 s */
_csEqOverlayTimer = setTimeout(() => {
overlay.classList.remove('visible');
_csEqOverlayTimer = null;
}, 5000);
}
/* ── Cell Division ── */