Files
Learn_System/frontend/js/labs/chemsandbox.js
T
Maxim Dolgolyov 6de91f7595 fix(labs): SVG markup rendered as text in 6 simulations
Hardcoded inline <svg class="ic"> markers used as arrow replacements

(left over from emoji removal) were displayed as raw HTML text where

the consumer used textContent or canvas fillText:

- chemsandbox: csbar-v5 (Продукты cell) used textContent → SVG visible.

  Switched to innerHTML for consistency with eq/ionNet cells.

  Quiz question (qEl.textContent) and answer also receiving SVG —

  cleaned via _csClean at source.

- reactions: modeTxt drawn via canvas fillText — replaced SVG with →.

- ionexchange: REACTIONS data + canvas labels — bulk SVG → Unicode arrows.

- newton: action button labels used textContent → switched to innerHTML;

  canvas arrow labels: SVG → Unicode →/↓.

- collision: 'KE сохранена' canvas label — SVG checkmark → ✓.

- projectile: canvas badges + textContent wind label — SVG → Unicode ←/→/↩.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:47:50 +03:00

1840 lines
99 KiB
JavaScript
Raw 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;
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];
// 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,
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);
}
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 (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();
}
}
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();
}
_drawBackground() {
const { ctx, W, H } = this;
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(); }
}
_drawFlaskShadow() {
const { ctx } = this;
const { cx, cy, r } = this._g;
// shadow beneath flask
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();
}
_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;
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);
ctx.font = '36px serif';
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillText('\u{1F9EA}', W / 2, cy - 30);
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();
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) { 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 {
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(() => {});
}
}
/* ── Cell Division ── */