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