6de91f7595
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>
1840 lines
99 KiB
JavaScript
1840 lines
99 KiB
JavaScript
'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 ── */
|