Files
Learn_System/frontend/js/labs/chemsandbox.js
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

1683 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
return '';
});
}
/* ════════════════════════════════════════════════════════════════
ChemSandboxSim v2 — «Химическая песочница»
• Колба Эрленмейера с реалистичным стеклом
• Анимация вливания реагента сверху
• Удаление отдельного реагента из зоны
• Drag реагентов с полки
• Визуальные эффекты: осадки, газы, смена цвета, нагрев
════════════════════════════════════════════════════════════════ */
class ChemSandboxSim {
/* ── База веществ ─────────────────────────────────────────────── */
static SUBSTANCES = {
'HCl': { name: 'Соляная к-та', state: 'aq', color: '#78D278', cat: 'acid' },
'H2SO4': { name: 'Серная к-та', state: 'aq', color: '#D2C378', cat: 'acid' },
'HNO3': { name: 'Азотная к-та', state: 'aq', color: '#E8D060', cat: 'acid' },
'CH3COOH': { name: 'Уксусная к-та', state: 'aq', color: '#C8E0A0', cat: 'acid' },
'NaOH': { name: 'Гидроксид натрия', state: 'aq', color: '#7BF5A4', cat: 'base' },
'KOH': { name: 'Гидроксид калия', state: 'aq', color: '#7BF5A4', cat: 'base' },
'Ca(OH)2': { name: 'Гидроксид кальция', state: 'aq', color: '#E0E0E0', cat: 'base' },
'NH3·H2O': { name: 'Аммиачная вода', state: 'aq', color: '#A0D8F0', cat: 'base' },
'NaCl': { name: 'Хлорид натрия', state: 's', color: '#FFFFFF', cat: 'salt' },
'CuSO4': { name: 'Сульфат меди', state: 'aq', color: '#4CC9F0', cat: 'salt' },
'BaCl2': { name: 'Хлорид бария', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'AgNO3': { name: 'Нитрат серебра', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'Na2CO3': { name: 'Карбонат натрия', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'FeCl3': { name: 'Хлорид железа(III)', state: 'aq', color: '#D4A040', cat: 'salt' },
'Pb(NO3)2':{ name: 'Нитрат свинца', state: 'aq', color: '#E0E0E0', cat: 'salt' },
'K2CrO4': { name: 'Хромат калия', state: 'aq', color: '#FFD700', cat: 'salt' },
'Zn': { name: 'Цинк', state: 's', color: '#9BB8CC', cat: 'metal' },
'Fe': { name: 'Железо', state: 's', color: '#A08060', cat: 'metal' },
'Cu': { name: 'Медь', state: 's', color: '#C87840', cat: 'metal' },
'Mg': { name: 'Магний', state: 's', color: '#D6D6D6', cat: 'metal' },
'Na': { name: 'Натрий', state: 's', color: '#F5F0C8', cat: 'metal' },
'H2O': { name: 'Вода', state: 'l', color: '#6EB4D7', cat: 'other' },
'Phenolphthalein': { name: 'Фенолфталеин', state: 'ind', color: '#E0E0E0', cat: 'indicator' },
'Litmus': { name: 'Лакмус', state: 'ind', color: '#9B59B6', cat: 'indicator' },
'MethylOrange': { name: 'Метилоранж', state: 'ind', color: '#FF8C00', cat: 'indicator' },
};
/* ── База реакций ─────────────────────────────────────────────── */
static REACTIONS = [
// ── Нейтрализация ──
{ r: ['HCl','NaOH'], eq: 'HCl + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaCl + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + Cl⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['H2SO4','NaOH'], eq: 'H₂SO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HNO3','NaOH'], eq: 'HNO₃ + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> NaNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + NO₃⁻ + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HCl','KOH'], eq: 'HCl + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KCl + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + Cl⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + Cl⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HCl','Ca(OH)2'], eq: '2HCl + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaCl₂ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + 2Cl⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Ca²⁺ + 2Cl⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['CH3COOH','NaOH'], eq: 'CH₃COOH + NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COONa + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'CH₃COOH + Na⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + Na⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
why: 'Слабая кислота реагирует с OH⁻ целиком (не диссоциирует полностью)' },
{ r: ['H2SO4','KOH'], eq: 'H₂SO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K₂SO₄ + 2H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: '2H⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2K⁺ + SO₄²⁻ + 2H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['HNO3','KOH'], eq: 'HNO₃ + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> KNO₃ + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'H⁺ + NO₃⁻ + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> K⁺ + NO₃⁻ + H₂O', ionNet: 'H⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O',
why: 'Кислота отдаёт H⁺, основание — OH⁻; они образуют воду' },
{ r: ['CH3COOH','KOH'], eq: 'CH₃COOH + KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COOK + H₂O', type: 'Нейтрализация', fx: { heat: true },
ionFull: 'CH₃COOH + K⁺ + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + K⁺ + H₂O', ionNet: 'CH₃COOH + OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CH₃COO⁻ + H₂O',
why: 'Слабая кислота реагирует с OH⁻ целиком' },
{ r: ['H2SO4','Ca(OH)2'], eq: 'H₂SO₄ + Ca(OH)₂ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O', type: 'Нейтрализация', fx: { heat: true, precip: { c: '#F0F0F0', n: 'CaSO₄' } },
ionFull: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O', ionNet: '2H⁺ + SO₄²⁻ + Ca²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> CaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H₂O',
why: 'Нейтрализация + образование малорастворимого CaSO₄' },
// ── Замещение (металл + кислота) ──
{ r: ['Zn','HCl'], eq: 'Zn + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Zn⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Zn','H2SO4'], eq: 'Zn + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Zn⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Zn⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Zn активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Fe','HCl'], eq: 'Fe + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Fe⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Fe','H2SO4'], eq: 'Fe + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true },
ionFull: 'Fe⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Fe⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Fe активнее H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет водород' },
{ r: ['Mg','HCl'], eq: 'Mg + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgCl₂ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true },
ionFull: 'Mg⁰ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + 2Cl⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
{ r: ['Mg','H2SO4'], eq: 'Mg + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> MgSO₄ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Замещение', fx: { gas: 'H₂', heat: true, violent: true },
ionFull: 'Mg⁰ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + SO₄²⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'Mg⁰ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg²⁺ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg очень активен <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурно вытесняет водород' },
// ── Замещение (металл + соль) ──
{ r: ['CuSO4','Fe'], eq: 'CuSO₄ + Fe <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> FeSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#90C090' },
ionFull: 'Cu²⁺ + SO₄²⁻ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Fe⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
{ r: ['CuSO4','Zn'], eq: 'CuSO₄ + Zn <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ZnSO₄ + Cu<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#E8913A', n: 'Cu' }, colorTo: '#E0E0E0' },
ionFull: 'Cu²⁺ + SO₄²⁻ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + SO₄²⁻ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: 'Cu²⁺ + Zn⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Zn²⁺ + Cu⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Zn активнее Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет медь из раствора' },
{ r: ['AgNO3','Cu'], eq: '2AgNO₃ + Cu <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(NO₃)₂ + 2Ag<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', type: 'Замещение', fx: { precip: { c: '#C0C0C0', n: 'Ag' }, colorTo: '#4CC9F0' },
ionFull: '2Ag⁺ + 2NO₃⁻ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2NO₃⁻ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>', ionNet: '2Ag⁺ + Cu⁰ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu²⁺ + 2Ag⁰<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu активнее Ag <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> вытесняет серебро из раствора' },
// ── Обмен с осадком ──
{ r: ['AgNO3','NaCl'], eq: 'AgNO₃ + NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
ionFull: 'Ag⁺ + NO₃⁻ + Na⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['AgNO3','HCl'], eq: 'AgNO₃ + HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + HNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'AgCl' } },
ionFull: 'Ag⁺ + NO₃⁻ + H⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H⁺ + NO₃⁻', ionNet: 'Ag⁺ + Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> AgCl<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'AgCl нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['BaCl2','H2SO4'], eq: 'BaCl₂ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2HCl', type: 'Обмен', fx: { precip: { c: '#FFFFFF', n: 'BaSO₄' } },
ionFull: 'Ba²⁺ + 2Cl⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2H⁺ + 2Cl⁻', ionNet: 'Ba²⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> BaSO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'BaSO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ионы связываются в осадок' },
{ r: ['CuSO4','NaOH'], eq: 'CuSO₄ + 2NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + Na₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
ionFull: 'Cu²⁺ + SO₄²⁻ + 2Na⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> осаждается голубой гидроксид' },
{ r: ['FeCl3','NaOH'], eq: 'FeCl₃ + 3NaOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3NaCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
ionFull: 'Fe³⁺ + 3Cl⁻ + 3Na⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3Na⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
{ r: ['Pb(NO3)2','K2CrO4'],eq: 'Pb(NO₃)₂ + K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2KNO₃',type:'Обмен', fx: { precip: { c: '#FFD700', n: 'PbCrO₄' } },
ionFull: 'Pb²⁺ + 2NO₃⁻ + 2K⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCrO₄<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'PbCrO₄ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> яркий жёлтый осадок' },
{ r: ['FeCl3','K2CrO4'], eq: '2FeCl₃ + 3K₂CrO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6KCl',type:'Обмен', fx: { precip: { c: '#8B6914', n: 'Fe₂(CrO₄)₃' } },
ionFull: '2Fe³⁺ + 6Cl⁻ + 6K⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 6K⁺ + 6Cl⁻', ionNet: '2Fe³⁺ + 3CrO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe₂(CrO₄)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe₂(CrO₄)₃ нерастворим' },
{ r: ['Pb(NO3)2','NaCl'], eq: 'Pb(NO₃)₂ + 2NaCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2NaNO₃', type: 'Обмен', fx: { precip: { c: '#F0F0F0', n: 'PbCl₂' } },
ionFull: 'Pb²⁺ + 2NO₃⁻ + 2Na⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2Na⁺ + 2NO₃⁻', ionNet: 'Pb²⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> PbCl₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'PbCl₂ малорастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> белый осадок' },
{ r: ['CuSO4','KOH'], eq: 'CuSO₄ + 2KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + K₂SO₄', type: 'Обмен', fx: { precip: { c: '#5BC0EB', n: 'Cu(OH)₂' } },
ionFull: 'Cu²⁺ + SO₄²⁻ + 2K⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 2K⁺ + SO₄²⁻', ionNet: 'Cu²⁺ + 2OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Cu(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Cu(OH)₂ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> голубой осадок' },
{ r: ['FeCl3','KOH'], eq: 'FeCl₃ + 3KOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3KCl', type: 'Обмен', fx: { precip: { c: '#8B4513', n: 'Fe(OH)₃' } },
ionFull: 'Fe³⁺ + 3Cl⁻ + 3K⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + 3K⁺ + 3Cl⁻', ionNet: 'Fe³⁺ + 3OH⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Fe(OH)₃<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
why: 'Fe(OH)₃ нерастворим <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бурый осадок' },
// ── Обмен с газом ──
{ r: ['Na2CO3','HCl'], eq: 'Na₂CO₃ + 2HCl <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaCl + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2Cl⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2Cl⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','H2SO4'], eq: 'Na₂CO₃ + H₂SO₄ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Na₂SO₄ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + SO₄²⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + SO₄²⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','HNO3'], eq: 'Na₂CO₃ + 2HNO₃ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaNO₃ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2H⁺ + 2NO₃⁻ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2NO₃⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2H⁺ <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'H₂CO₃ неустойчива <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> разлагается на воду и газ CO₂' },
{ r: ['Na2CO3','CH3COOH'], eq: 'Na₂CO₃ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COONa + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',type:'Обмен', fx: { gas: 'CO₂' },
ionFull: '2Na⁺ + CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + 2Na⁺ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', ionNet: 'CO₃²⁻ + 2CH₃COOH <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2CH₃COO⁻ + H₂O + CO₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Уксусная кислота слабая, но карбонат-ион связывает H⁺' },
// ── Активный металл + вода ──
{ r: ['Na','H2O'], eq: '2Na + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2NaOH + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, violent: true },
ionNet: '2Na⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 2Na⁺ + 2OH⁻ + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Na — щелочной металл, бурно реагирует с водой' },
{ r: ['Mg','H2O'], eq: 'Mg + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>', type: 'Акт. металл', fx: { gas: 'H₂', heat: true, precip: { c: '#E0E0E0', n: 'Mg(OH)₂' } },
ionNet: 'Mg⁰ + 2H₂O <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> Mg(OH)₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg> + H₂<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>',
why: 'Mg реагирует с горячей водой, Mg(OH)₂ малорастворим' },
// ── Индикаторы ──
{ r: ['Phenolphthalein','NaOH'], eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','KOH'], eq: 'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','Ca(OH)2'],eq:'Фенолфталеин + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> малиновый', type: 'Индикатор', fx: { colorTo: '#FF1493' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> фенолфталеин приобретает малиновую окраску' },
{ r: ['Phenolphthalein','NH3·H2O'],eq:'Фенолфталеин + аммиак <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> бл.-розовый', type: 'Индикатор', fx: { colorTo: '#FFB0C0' },
why: 'NH₃·H₂O — слабое основание, pH ~11, бледная окраска' },
{ r: ['Litmus','HCl'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','H2SO4'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','HNO3'], eq: 'Лакмус + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> красный', type: 'Индикатор', fx: { colorTo: '#EF476F' },
why: 'pH < 5 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус краснеет' },
{ r: ['Litmus','NaOH'], eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий', type: 'Индикатор', fx: { colorTo: '#4466FF' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
{ r: ['Litmus','KOH'], eq: 'Лакмус + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> синий', type: 'Индикатор', fx: { colorTo: '#4466FF' },
why: 'pH > 8 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> лакмус синеет' },
{ r: ['MethylOrange','HCl'], eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' },
why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
{ r: ['MethylOrange','H2SO4'], eq: 'Метилоранж + кислота <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> розовый', type: 'Индикатор', fx: { colorTo: '#FF6666' },
why: 'pH < 3.1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж розовеет' },
{ r: ['MethylOrange','NaOH'], eq: 'Метилоранж + щёлочь <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> жёлтый', type: 'Индикатор', fx: { colorTo: '#FFD700' },
why: 'pH > 4.4 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> метилоранж жёлтый' },
// ── Нет реакции ──
{ r: ['Cu','HCl'], eq: 'Cu + HCl — реакция не идёт', type: 'Нет реакции', fx: { none: true },
why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
{ r: ['Cu','H2SO4'], eq: 'Cu + H₂SO₄(разб.) — нет реакции',type: 'Нет реакции', fx: { none: true },
why: 'Cu стоит после H в ряду активности <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> не вытесняет водород' },
];
/* ── Ряд активности металлов ─────────────────────────────────── */
static ACTIVITY_SERIES = [
{ sym: 'K', name: 'Калий' },
{ sym: 'Na', name: 'Натрий' },
{ sym: 'Ca', name: 'Кальций' },
{ sym: 'Mg', name: 'Магний' },
{ sym: 'Al', name: 'Алюминий' },
{ sym: 'Zn', name: 'Цинк' },
{ sym: 'Fe', name: 'Железо' },
{ sym: 'Ni', name: 'Никель' },
{ sym: 'Sn', name: 'Олово' },
{ sym: 'Pb', name: 'Свинец' },
{ sym: 'H₂', name: 'Водород' },
{ sym: 'Cu', name: 'Медь' },
{ sym: 'Ag', name: 'Серебро' },
{ sym: 'Au', name: 'Золото' },
];
static PRESETS = {
neutralization: ['HCl', 'NaOH'],
gas_evolution: ['Na2CO3', 'HCl'],
precipitate: ['AgNO3', 'NaCl'],
displacement: ['CuSO4', 'Fe'],
indicator: ['Phenolphthalein', 'NaOH'],
violent: ['Na', 'H2O'],
yellow_precip: ['Pb(NO3)2', 'K2CrO4'],
blue_precip: ['CuSO4', 'NaOH'],
};
/* ── Конструктор ─────────────────────────────────────────────── */
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.W = 0; this.H = 0;
this.mixContents = [];
this.lastReaction = null;
this.filterCat = 'all';
// liquid state
this._liqColor = null;
this._liqTargetColor = null;
this._liqLevel = 0;
this._precipLevel = 0;
this._precipColor = null;
// particles
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._pourDrops = []; // pour animation
// animation
this._raf = null;
this._last = 0;
this._time = 0;
this._animPhase = 0;
this._reacTimer = 0;
// flask geometry cache
this._g = {};
// glow / waves
this._glowPulse = 0;
this._heatGlow = 0;
this._wave = 0;
this._wave2 = 0;
this._wave3 = 0;
// gas label
this._gasLabel = null;
// pour animation state
this._pouring = false;
this._pourColor = null;
this._pourTimer = 0;
// drag state
this._drag = null; // { formula, x, y }
// added items chips (for removal)
this._chipLayout = []; // [{formula, x, y, w, h}]
// shelf layout cache
this._shelfKeys = [];
this._shelfStartX = 0;
this._shelfBottleW = 0;
this._shelfGap = 0;
this._shelfBottleY = 0;
this._shelfBottleH = 0;
this._shelfLayout = []; // [{formula, x, y, w, h}]
this._shelfScroll = 0; // horizontal scroll offset
this._shelfHover = -1; // hovered card index
// pending preset timeouts
this._presetTimers = [];
// quiz mode
this._quizMode = false;
this._quizTask = null; // { rx, question, answer: [f1,f2] }
this._quizScore = 0;
this._quizTotal = 0;
this._quizResult = null; // 'correct' | 'wrong' | null
this._quizResultT = 0;
this.onUpdate = null;
this.onQuizUpdate = null; // callback(quizInfo)
this.fit();
}
/* ── Геометрия колбы Эрленмейера ─────────────────────────────── */
fit() {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.offsetWidth || 600;
const H = this.canvas.offsetHeight || 400;
this.canvas.width = Math.round(W * dpr);
this.canvas.height = Math.round(H * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = W; this.H = H;
this._calcGeom();
}
_calcGeom() {
const { W, H } = this;
// flask parameters (Erlenmeyer)
const r = Math.min(W * 0.17, H * 0.18); // body radius (smaller to fit info)
const cx = W * 0.50;
const cy = H * 0.34; // body center (higher)
const nw = r * 0.22; // neck half-width
const nh = r * 0.90; // neck height
const nt = cy - r - nh; // neck top Y
const nb = cy - r * 0.75; // neck-shoulder transition Y
const liqTop = cy - r * 0.35; // default liquid surface
// shelf — 2-row grid with large cards
const shelfH = 140;
const shelfY = H - shelfH - 4;
this._g = { r, cx, cy, nw, nh, nt, nb, liqTop, shelfY, shelfH };
}
_flaskPath(ctx) {
const { r, cx, cy, nw, nt, nb } = this._g;
ctx.beginPath();
ctx.moveTo(cx - nw, nt);
ctx.lineTo(cx - nw, nb);
ctx.bezierCurveTo(cx - nw, cy - r * 0.38, cx - r * 0.85, cy - r * 0.08, cx - r, cy);
ctx.arc(cx, cy, r, Math.PI, 0, true);
ctx.bezierCurveTo(cx + r * 0.85, cy - r * 0.08, cx + nw, cy - r * 0.38, cx + nw, nb);
ctx.lineTo(cx + nw, nt);
ctx.closePath();
}
/* ── Запуск / остановка ─────────────────────────────────────── */
start() {
if (this._raf) return;
this._last = performance.now();
const loop = t => { this._raf = requestAnimationFrame(loop); this._tick(t); };
this._raf = requestAnimationFrame(loop);
}
stop() { cancelAnimationFrame(this._raf); this._raf = null; }
/* ── Публичный API ──────────────────────────────────────────── */
reset() {
// cancel any pending preset timers
for (const t of this._presetTimers) clearTimeout(t);
this._presetTimers = [];
this.mixContents = [];
this.lastReaction = null;
this._liqColor = null;
this._liqTargetColor = null;
this._liqLevel = 0;
this._precipLevel = 0;
this._precipColor = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._pourDrops = [];
this._animPhase = 0;
this._reacTimer = 0;
this._heatGlow = 0;
this._gasLabel = null;
this._pouring = false;
this._chipLayout = [];
this._fireInfo();
}
resetReaction() {
// keep reagents in the zone, but clear reaction effects
this.lastReaction = null;
this._liqTargetColor = null;
this._animPhase = 0;
this._reacTimer = 0;
this._gasLabel = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._precipLevel = 0;
this._precipColor = null;
this._heatGlow = 0;
// recalc liquid to original colors without reaction effects
this._recalcLiquid();
this._fireInfo();
}
addToMix(formula) {
if (this.mixContents.length >= 4) return;
if (this.mixContents.includes(formula)) return;
this.mixContents.push(formula);
const sub = ChemSandboxSim.SUBSTANCES[formula];
// pour animation
this._pouring = true;
this._pourColor = sub.color;
this._pourTimer = 0;
this._spawnPourDrops(sub.color, sub.state === 's');
// liquid level (cap at 0.85 to keep within flask body)
if (sub.state !== 's') {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
this._liqColor = this._liqColor
? this._blendColors(this._liqColor, sub.color, 0.45)
: sub.color;
} else {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
}
// check reaction
this._checkReaction();
this._fireInfo();
}
removeFromMix(formula) {
const idx = this.mixContents.indexOf(formula);
if (idx === -1) return;
this.mixContents.splice(idx, 1);
// recalculate liquid
this._recalcLiquid();
// re-check reaction
this.lastReaction = null;
this._animPhase = 0;
this._gasLabel = null;
this._bubbles = [];
this._precipParts = [];
this._steamParts = [];
this._sparkParts = [];
this._precipLevel = 0;
this._precipColor = null;
this._heatGlow = 0;
this._liqTargetColor = null;
if (this.mixContents.length >= 2) this._checkReaction();
this._fireInfo();
}
_recalcLiquid() {
this._liqLevel = 0;
this._liqColor = null;
for (const f of this.mixContents) {
const s = ChemSandboxSim.SUBSTANCES[f];
if (s.state !== 's') {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.30);
this._liqColor = this._liqColor
? this._blendColors(this._liqColor, s.color, 0.45)
: s.color;
} else {
this._liqLevel = Math.min(0.85, this._liqLevel + 0.10);
}
}
}
setCategory(cat) { this.filterCat = cat; }
preset(name) {
const p = ChemSandboxSim.PRESETS[name];
if (!p) return;
this.reset();
// add with staggered delay (store timer IDs so reset can cancel them)
this._presetTimers = p.map((f, i) =>
setTimeout(() => this.addToMix(f), i * 600)
);
}
info() {
const r = this.lastReaction;
return {
mixed: this.mixContents.length,
contents: [...this.mixContents],
reaction: r && !r.fx.none ? true : false,
type: r ? r.type : null,
equation: r ? r.eq : null,
products: r && !r.fx.none ? this._productsStr(r) : null,
ionNet: r ? r.ionNet || null : null,
why: r ? r.why || null : null,
};
}
/* ── Поиск реакции ─────────────────────────────────────────── */
_checkReaction() {
const c = this.mixContents;
if (c.length < 2) return;
for (let i = 0; i < c.length; i++) {
for (let j = i + 1; j < c.length; j++) {
const rx = this._findReaction(c[i], c[j]);
if (rx) {
this.lastReaction = rx;
this._triggerReaction(rx);
if (this._quizMode) this._checkQuizAnswer();
return;
}
}
}
}
_findReaction(a, b) {
return ChemSandboxSim.REACTIONS.find(rx =>
(rx.r[0] === a && rx.r[1] === b) || (rx.r[0] === b && rx.r[1] === a)
) || null;
}
_productsStr(rx) {
const parts = rx.eq.split('<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>');
return parts.length > 1 ? parts[1].trim() : '—';
}
/* ── Эффекты реакции ────────────────────────────────────────── */
_triggerReaction(rx) {
const fx = rx.fx;
if (fx.none) { this._fireInfo(); return; }
this._animPhase = 1;
this._reacTimer = 0;
if (fx.colorTo) this._liqTargetColor = fx.colorTo;
if (fx.gas) { this._gasLabel = fx.gas; this._spawnBubbles(fx.violent ? 45 : 22); }
if (fx.precip) { this._precipColor = fx.precip.c; this._spawnPrecipitate(28); }
if (fx.heat) {
this._heatGlow = 1.0;
this._spawnSteam(fx.violent ? 25 : 12);
if (fx.violent) this._spawnSparks(35);
}
this._fireInfo();
}
/* ── Частицы ────────────────────────────────────────────────── */
_spawnPourDrops(color, isSolid) {
const { cx, nt, nw } = this._g;
const n = isSolid ? 8 : 15;
for (let i = 0; i < n; i++) {
this._pourDrops.push({
x: cx + (Math.random() - 0.5) * nw * 1.5,
y: nt - 20 - Math.random() * 30,
r: isSolid ? 2 + Math.random() * 3 : 1.5 + Math.random() * 2,
vy: 1.5 + Math.random() * 3,
vx: (Math.random() - 0.5) * 0.8,
color,
life: 1.0,
solid: isSolid,
});
}
}
_spawnBubbles(n) {
const { cx, cy, r } = this._g;
for (let i = 0; i < n; i++) {
const angle = (Math.random() - 0.5) * 1.2;
const dist = Math.random() * r * 0.6;
this._bubbles.push({
x: cx + Math.sin(angle) * dist,
y: cy + r * 0.3 - Math.random() * 15,
r: 1.5 + Math.random() * 4.5,
vy: -(1.5 + Math.random() * 3),
vx: (Math.random() - 0.5) * 0.8,
life: 1.0,
delay: Math.random() * 2.5,
});
}
}
_spawnPrecipitate(n) {
const { cx, cy, r } = this._g;
for (let i = 0; i < n; i++) {
this._precipParts.push({
x: cx + (Math.random() - 0.5) * r * 1.2,
y: cy - r * 0.2 + Math.random() * r * 0.4,
r: 1.5 + Math.random() * 3,
vy: 0.3 + Math.random() * 0.9,
vx: (Math.random() - 0.5) * 0.3,
life: 1.0,
delay: Math.random() * 1.8,
});
}
}
_spawnSteam(n) {
const { cx, nt, nw } = this._g;
for (let i = 0; i < n; i++) {
this._steamParts.push({
x: cx + (Math.random() - 0.5) * nw * 2,
y: nt - 5 + Math.random() * 10,
r: 3 + Math.random() * 7,
vy: -(0.5 + Math.random() * 1.8),
vx: (Math.random() - 0.5) * 0.6,
life: 1.0,
delay: Math.random() * 2.5,
});
}
}
_spawnSparks(n) {
const { cx, nt, nw } = this._g;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const sp = 2 + Math.random() * 5;
this._sparkParts.push({
x: cx + (Math.random() - 0.5) * nw,
y: nt + 5,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 3,
life: 1.0,
delay: Math.random() * 1.0,
});
}
}
/* ── Тик ────────────────────────────────────────────────────── */
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
this._time += dt;
this._wave += dt * 1.7;
this._wave2 += dt * 2.3;
this._wave3 += dt * 0.88;
this._glowPulse += dt * 3.2;
if (this._animPhase === 1) {
this._reacTimer += dt;
if (this._reacTimer > 5.0) this._animPhase = 2;
}
// color lerp
if (this._liqTargetColor && this._liqColor) {
this._liqColor = this._lerpColor(this._liqColor, this._liqTargetColor, dt * 1.0);
}
if (this._heatGlow > 0) this._heatGlow = Math.max(0, this._heatGlow - dt * 0.12);
// pour animation
if (this._pouring) {
this._pourTimer += dt;
if (this._pourTimer > 1.2) this._pouring = false;
}
// quiz result timer
if (this._quizResultT > 0) {
this._quizResultT -= dt;
if (this._quizResultT <= 0) {
this._quizResultT = 0;
if (this._quizResult === 'correct') this._nextQuizTask();
}
}
this._updatePour(dt);
this._updateBubbles(dt);
this._updatePrecip(dt);
this._updateSteam(dt);
this._updateSparks(dt);
this.draw();
}
_updatePour(dt) {
const { cy, r } = this._g;
const surfY = this._getSurfaceY();
for (let i = this._pourDrops.length - 1; i >= 0; i--) {
const d = this._pourDrops[i];
d.vy += 8 * dt; // gravity
d.y += d.vy;
d.x += d.vx;
if (d.y > surfY) {
d.life -= dt * 3;
}
if (d.life <= 0 || d.y > cy + r) this._pourDrops.splice(i, 1);
}
}
_updateBubbles(dt) {
const surfY = this._getSurfaceY();
for (let i = this._bubbles.length - 1; i >= 0; i--) {
const b = this._bubbles[i];
if (b.delay > 0) { b.delay -= dt; continue; }
b.x += b.vx;
b.y += b.vy;
b.vx += (Math.random() - 0.5) * 0.4;
b.life -= dt * 0.22;
if (b.y < surfY - 8 || b.life <= 0) this._bubbles.splice(i, 1);
}
if (this._animPhase === 1 && this._gasLabel && Math.random() < 0.35) {
this._spawnBubbles(2);
}
}
_updatePrecip(dt) {
const { cy, r } = this._g;
const bottom = cy + r - 14;
for (let i = this._precipParts.length - 1; i >= 0; i--) {
const p = this._precipParts[i];
if (p.delay > 0) { p.delay -= dt; continue; }
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.99;
if (p.y >= bottom) {
p.y = bottom; p.vy = 0; p.vx = 0;
this._precipLevel = Math.min(1, this._precipLevel + 0.004);
p.life -= dt * 0.25;
}
if (p.life <= 0) this._precipParts.splice(i, 1);
}
}
_updateSteam(dt) {
for (let i = this._steamParts.length - 1; i >= 0; i--) {
const s = this._steamParts[i];
if (s.delay > 0) { s.delay -= dt; continue; }
s.x += s.vx; s.y += s.vy;
s.r += dt * 1.8; s.life -= dt * 0.35;
if (s.life <= 0) this._steamParts.splice(i, 1);
}
}
_updateSparks(dt) {
for (let i = this._sparkParts.length - 1; i >= 0; i--) {
const s = this._sparkParts[i];
if (s.delay > 0) { s.delay -= dt; continue; }
s.x += s.vx; s.y += s.vy;
s.vy += 4 * dt;
s.life -= dt * 0.7;
if (s.life <= 0) this._sparkParts.splice(i, 1);
}
}
_getSurfaceY() {
const { cy, r, nt, nb } = this._g;
if (this._liqLevel <= 0) return cy + r;
// total fillable height: from flask bottom (cy+r) up to just below neck-shoulder (nb+4)
const maxH = (cy + r) - (nb + 4);
const liqH = maxH * Math.min(1, this._liqLevel) * 0.75;
return cy + r - liqH;
}
/* ── Рендеринг ─────────────────────────────────────────────── */
draw() {
const { ctx, W, H } = this;
ctx.clearRect(0, 0, W, H);
this._drawBackground();
this._drawFlaskShadow();
this._drawLiquid();
this._drawPrecipitate();
this._drawBubbles();
this._drawPourDrops();
this._drawFlaskGlass();
this._drawSteam();
this._drawSparks();
this._drawChips();
this._drawEquation();
this._drawShelf();
this._drawDragGhost();
if (this.mixContents.length === 0 && !this.lastReaction && !this._quizMode) this._drawHint();
if (this._quizMode) this._drawQuizOverlay();
}
_drawBackground() {
const { ctx, W, H } = this;
const bg = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.75);
bg.addColorStop(0, '#0c0c1a');
bg.addColorStop(1, '#050508');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// grid
ctx.strokeStyle = 'rgba(255,255,255,0.02)';
ctx.lineWidth = 0.5;
for (let x = 0; x < W; x += 30) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 30) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
}
_drawFlaskShadow() {
const { ctx } = this;
const { cx, cy, r } = this._g;
// shadow beneath flask
const sg = ctx.createRadialGradient(cx, cy + r + 8, 0, cx, cy + r + 8, r * 1.1);
sg.addColorStop(0, 'rgba(0,0,0,0.25)');
sg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = sg;
ctx.fillRect(cx - r * 1.2, cy + r - 2, r * 2.4, 25);
}
_drawLiquid() {
if (this._liqLevel <= 0) return;
const { ctx } = this;
const g = this._g;
const surfY = this._getSurfaceY();
ctx.save();
this._flaskPath(ctx);
ctx.clip();
const col = this._liqColor || '#6EB4D7';
const liqGrad = ctx.createLinearGradient(0, surfY, 0, g.cy + g.r);
liqGrad.addColorStop(0, this._alphaColor(col, 0.45));
liqGrad.addColorStop(0.5, this._alphaColor(col, 0.60));
liqGrad.addColorStop(1, this._alphaColor(col, 0.75));
ctx.fillStyle = liqGrad;
// wavy surface
ctx.beginPath();
ctx.moveTo(g.cx - g.r - 5, g.cy + g.r + 5);
const amp = 2.0;
const left = g.cx - g.r - 5, right = g.cx + g.r + 5;
for (let px = left; px <= right; px += 2) {
const t = (px - left) / (right - left);
const wy = surfY + Math.sin(t * 7 + this._wave) * amp
+ Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
ctx.lineTo(px, wy);
}
ctx.lineTo(right, g.cy + g.r + 5);
ctx.closePath();
ctx.fill();
// meniscus highlight
ctx.beginPath();
for (let px = left; px <= right; px += 2) {
const t = (px - left) / (right - left);
const wy = surfY + Math.sin(t * 7 + this._wave) * amp
+ Math.sin(t * 4.5 + this._wave2) * amp * 0.6;
if (px === left) ctx.moveTo(px, wy); else ctx.lineTo(px, wy);
}
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
// SSS (sub-surface scattering glow)
const ssg = ctx.createRadialGradient(g.cx, surfY + 20, 5, g.cx, surfY + 20, g.r * 0.8);
ssg.addColorStop(0, this._alphaColor(col, 0.12));
ssg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = ssg;
ctx.fillRect(left, surfY, right - left, g.r * 2);
ctx.restore();
}
_drawPrecipitate() {
const { ctx } = this;
const { cx, cy, r } = this._g;
if (this._precipLevel <= 0 && this._precipParts.length === 0) return;
// everything clipped to flask shape
ctx.save();
this._flaskPath(ctx);
ctx.clip();
// settled layer at flask bottom
if (this._precipLevel > 0 && this._precipColor) {
const layerH = r * 0.30 * this._precipLevel;
const layerY = cy + r - layerH;
const pg = ctx.createLinearGradient(0, layerY, 0, cy + r);
pg.addColorStop(0, this._alphaColor(this._precipColor, 0.35));
pg.addColorStop(1, this._alphaColor(this._precipColor, 0.65));
ctx.fillStyle = pg;
ctx.fillRect(cx - r - 2, layerY, r * 2 + 4, layerH);
}
// falling particles (also clipped)
for (const p of this._precipParts) {
if (p.delay > 0) continue;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = this._alphaColor(this._precipColor || '#FFF', p.life * 0.65);
ctx.fill();
}
ctx.restore();
}
_drawBubbles() {
const { ctx } = this;
for (const b of this._bubbles) {
if (b.delay > 0) continue;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,255,255,${b.life * 0.35})`;
ctx.lineWidth = 0.7;
ctx.stroke();
ctx.beginPath();
ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.25, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${b.life * 0.3})`;
ctx.fill();
}
// gas label above neck
if (this._gasLabel && this._bubbles.length > 0) {
const { cx, nt } = this._g;
ctx.save();
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(255,255,255,0.45)';
ctx.textAlign = 'center';
ctx.fillText(this._gasLabel + ' ↑', cx, nt - 14);
ctx.restore();
}
}
_drawPourDrops() {
const { ctx } = this;
for (const d of this._pourDrops) {
ctx.beginPath();
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
ctx.fillStyle = this._alphaColor(d.color, d.life * 0.7);
ctx.fill();
}
}
_drawSteam() {
const { ctx } = this;
for (const s of this._steamParts) {
if (s.delay > 0) continue;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,200,220,${s.life * 0.13})`;
ctx.fill();
}
}
_drawSparks() {
const { ctx } = this;
for (const s of this._sparkParts) {
if (s.delay > 0) continue;
ctx.save();
ctx.beginPath();
ctx.arc(s.x, s.y, 2.2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,200,60,${s.life * 0.85})`;
ctx.shadowColor = '#FFD060';
ctx.shadowBlur = 7;
ctx.fill();
ctx.restore();
}
}
_drawFlaskGlass() {
const { ctx } = this;
const g = this._g;
// glass body
ctx.save();
this._flaskPath(ctx);
// glass fill
const gf = ctx.createLinearGradient(g.cx - g.r, g.nt, g.cx + g.r, g.cy + g.r);
gf.addColorStop(0, 'rgba(255,255,255,0.05)');
gf.addColorStop(0.4, 'rgba(255,255,255,0.07)');
gf.addColorStop(1, 'rgba(255,255,255,0.03)');
ctx.fillStyle = gf;
ctx.fill();
// glass outline
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1.8;
ctx.stroke();
// heat glow
if (this._heatGlow > 0) {
ctx.shadowColor = `rgba(255,80,20,${this._heatGlow * 0.45})`;
ctx.shadowBlur = 30;
ctx.strokeStyle = `rgba(255,120,60,${this._heatGlow * 0.35})`;
ctx.stroke();
ctx.shadowBlur = 0;
}
ctx.restore();
// specular highlight — left edge
ctx.save();
ctx.beginPath();
ctx.moveTo(g.cx - g.nw + 2, g.nt + 6);
ctx.lineTo(g.cx - g.nw + 2, g.nb + 5);
ctx.bezierCurveTo(
g.cx - g.nw + 2, g.cy - g.r * 0.3,
g.cx - g.r * 0.75, g.cy - g.r * 0.05,
g.cx - g.r + 5, g.cy + g.r * 0.3
);
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.restore();
// neck rim
ctx.save();
ctx.beginPath();
ctx.ellipse(g.cx, g.nt, g.nw + 1, 3, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
// graduated marks on neck
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 0.5;
for (let i = 1; i <= 3; i++) {
const my = g.nt + (g.nb - g.nt) * i / 4;
ctx.beginPath();
ctx.moveTo(g.cx - g.nw + 3, my);
ctx.lineTo(g.cx - g.nw + 10, my);
ctx.stroke();
}
ctx.restore();
}
/* ── Чипы добавленных реагентов (с кнопкой удаления) ──────── */
_drawChips() {
if (this.mixContents.length === 0) return;
const { ctx, W } = this;
const { nt } = this._g;
this._chipLayout = [];
const chipH = 22, gap = 6;
let totalW = 0;
const labels = this.mixContents.map(f => {
const lbl = this._shortFormula(f);
ctx.font = 'bold 10px "JetBrains Mono", monospace';
const w = ctx.measureText(lbl).width + 28; // padding + "×" button
totalW += w + gap;
return { formula: f, label: lbl, w };
});
totalW -= gap;
let x = (W - totalW) / 2;
const y = Math.max(6, nt - 32);
for (const { formula, label, w } of labels) {
const sub = ChemSandboxSim.SUBSTANCES[formula];
// chip bg
ctx.save();
ctx.beginPath();
this._roundRect(ctx, x, y, w, chipH, 6);
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fill();
ctx.strokeStyle = this._alphaColor(sub.color, 0.4);
ctx.lineWidth = 1;
ctx.stroke();
// color dot
ctx.beginPath();
ctx.arc(x + 10, y + chipH / 2, 4, 0, Math.PI * 2);
ctx.fillStyle = sub.color;
ctx.fill();
// label
ctx.font = 'bold 10px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'left';
ctx.fillText(label, x + 18, y + chipH / 2 + 3.5);
// "×" remove button
const xBtnX = x + w - 14;
ctx.font = 'bold 11px sans-serif';
ctx.fillStyle = 'rgba(255,100,100,0.6)';
ctx.textAlign = 'center';
ctx.fillText('×', xBtnX, y + chipH / 2 + 4);
ctx.restore();
this._chipLayout.push({ formula, x, y, w, h: chipH, xBtnX });
x += w + gap;
}
}
_drawEquation() {
if (!this.lastReaction) return;
const { ctx, W } = this;
const { cy, r } = this._g;
const rx = this.lastReaction;
let y = cy + r + 14;
ctx.save();
ctx.textAlign = 'center';
// ── Молекулярное уравнение ──
ctx.font = 'bold 17px "JetBrains Mono", monospace';
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
ctx.fillText(_csClean(rx.eq), W / 2, y);
y += 22;
// ── Тип реакции + пояснение ──
const tp = rx.type;
const tpColor = tp === 'Нет реакции' ? '#EF476F'
: tp === 'Индикатор' ? '#9B59B6'
: tp === 'Нейтрализация' ? '#FFD166'
: tp === 'Замещение' ? '#06D6E0'
: tp === 'Обмен' ? '#7BF5A4'
: tp === 'Акт. металл' ? '#EF476F' : '#aaa';
ctx.font = 'bold 14px sans-serif';
ctx.fillStyle = tpColor;
ctx.fillText(tp, W / 2, y);
y += 18;
// why explanation
if (rx.why) {
ctx.font = '13px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(_csClean(rx.why), W / 2, y);
y += 17;
}
// ── Полное ионное уравнение ──
if (rx.ionFull) {
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(155,200,255,0.60)';
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
y += 16;
}
// ── Сокращённое ионное уравнение ──
if (rx.ionNet) {
ctx.font = 'bold 13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(123,245,164,0.75)';
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
y += 16;
}
ctx.restore();
// ── Мини ряд активности (для замещения) ──
if (rx.type === 'Замещение' || rx.type === 'Нет реакции' || rx.type === 'Акт. металл') {
this._drawActivitySeries(y + 2);
}
}
_drawActivitySeries(topY) {
const { ctx, W } = this;
const series = ChemSandboxSim.ACTIVITY_SERIES;
const rx = this.lastReaction;
if (!rx) return;
// find which metals are involved
const involved = new Set();
for (const f of rx.r) {
for (const m of series) {
if (f === m.sym || f === m.sym.replace('₂', '2')) involved.add(m.sym);
}
}
const metalMap = { 'Zn': 'Zn', 'Fe': 'Fe', 'Cu': 'Cu', 'Mg': 'Mg', 'Na': 'Na', 'Ag': 'Ag' };
for (const f of rx.r) {
if (metalMap[f]) involved.add(f);
}
for (const f of rx.r) {
if (f.startsWith('Cu')) involved.add('Cu');
if (f.startsWith('Ag')) involved.add('Ag');
if (f.startsWith('Fe') && !f.includes('Cl')) involved.add('Fe');
}
if (involved.size === 0) return;
const cellW = 28, cellH = 18, gap = 2;
const totalW = series.length * (cellW + gap) - gap;
const startX = (W - totalW) / 2;
const y = topY;
ctx.save();
ctx.font = '8px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.textAlign = 'center';
ctx.fillText('Ряд активности металлов', W / 2, y);
const rowY = y + 5;
for (let i = 0; i < series.length; i++) {
const m = series[i];
const x = startX + i * (cellW + gap);
const isInvolved = involved.has(m.sym) || involved.has(m.sym.replace('₂', '2'));
const isH = m.sym === 'H₂';
ctx.beginPath();
this._roundRect(ctx, x, rowY, cellW, cellH, 3);
ctx.fillStyle = isInvolved ? 'rgba(6,214,224,0.15)' : isH ? 'rgba(255,200,60,0.08)' : 'rgba(255,255,255,0.03)';
ctx.fill();
ctx.strokeStyle = isInvolved ? 'rgba(6,214,224,0.45)' : isH ? 'rgba(255,200,60,0.20)' : 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.7;
ctx.stroke();
ctx.font = isInvolved ? 'bold 9px "JetBrains Mono", monospace' : '8px "JetBrains Mono", monospace';
ctx.fillStyle = isInvolved ? '#06D6E0' : isH ? '#FFD166' : 'rgba(255,255,255,0.35)';
ctx.textAlign = 'center';
ctx.fillText(m.sym, x + cellW / 2, rowY + cellH / 2 + 3);
}
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.font = '9px sans-serif';
ctx.fillText('← активнее', startX + 40, rowY + cellH + 10);
ctx.fillText('менее активные →', startX + totalW - 60, rowY + cellH + 10);
ctx.restore();
}
_drawShelf() {
const { ctx, W } = this;
const { shelfY, shelfH } = this._g;
if (shelfH < 30) return;
// shelf background
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.012)';
ctx.fillRect(0, shelfY - 4, W, shelfH + 8);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(0, shelfY - 4); ctx.lineTo(W, shelfY - 4); ctx.stroke();
ctx.restore();
const subs = ChemSandboxSim.SUBSTANCES;
const keys = Object.keys(subs).filter(k =>
this.filterCat === 'all' || subs[k].cat === this.filterCat
);
// 2-row grid layout
const rows = 2;
const bW = 68, bH = 60, gapX = 7, gapY = 6;
const cols = Math.ceil(keys.length / rows);
const totalW = cols * (bW + gapX) - gapX;
const padX = 12;
const visW = W - padX * 2;
// clamp scroll
const maxScroll = Math.max(0, totalW - visW);
this._shelfScroll = Math.max(0, Math.min(maxScroll, this._shelfScroll));
const scrollOff = -this._shelfScroll;
const gridTop = shelfY + Math.max(2, (shelfH - (bH * rows + gapY * (rows - 1))) / 2);
// clip to shelf region
ctx.save();
ctx.beginPath();
ctx.rect(padX - 2, shelfY - 4, visW + 4, shelfH + 8);
ctx.clip();
// store layout for click detection
const layoutArr = [];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const s = subs[k];
const col = Math.floor(i / rows);
const row = i % rows;
const x = padX + col * (bW + gapX) + scrollOff;
const y = gridTop + row * (bH + gapY);
// skip if off-screen
if (x + bW < padX - 10 || x > W - padX + 10) {
layoutArr.push({ formula: k, x, y, w: bW, h: bH });
continue;
}
const inMix = this.mixContents.includes(k);
const isHov = this._shelfHover === i;
// card background
ctx.beginPath();
this._roundRect(ctx, x, y, bW, bH, 8);
const bgAlpha = inMix ? 0.16 : isHov ? 0.07 : 0.035;
ctx.fillStyle = inMix
? `rgba(75,205,155,${bgAlpha})`
: `rgba(255,255,255,${bgAlpha})`;
ctx.fill();
ctx.strokeStyle = inMix
? 'rgba(75,205,155,0.55)'
: isHov ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.07)';
ctx.lineWidth = inMix ? 1.6 : 1;
ctx.stroke();
// color swatch (rounded rect, not dot)
const swX = x + 8, swY = y + 7, swW = bW - 16, swH = 14;
ctx.beginPath();
this._roundRect(ctx, swX, swY, swW, swH, 4);
ctx.fillStyle = this._alphaColor(s.color, inMix ? 0.55 : 0.35);
ctx.fill();
// formula (main label)
ctx.font = 'bold 12px "JetBrains Mono", monospace';
ctx.fillStyle = inMix ? '#7BF5A4' : 'rgba(255,255,255,0.75)';
ctx.textAlign = 'center';
ctx.fillText(this._shortFormula(k), x + bW / 2, y + 37);
// name
ctx.font = '8.5px "Manrope", sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.30)';
ctx.fillText(s.name.substring(0, 13), x + bW / 2, y + 50);
// in-mix badge
if (inMix) {
ctx.beginPath();
ctx.arc(x + bW - 9, y + 9, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(75,205,155,0.65)';
ctx.fill();
ctx.font = 'bold 9px sans-serif';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText('✓', x + bW - 9, y + 12.5);
}
layoutArr.push({ formula: k, x, y, w: bW, h: bH });
}
ctx.restore();
// scroll arrows if content overflows
if (maxScroll > 0) {
ctx.save();
const arrowY = shelfY + shelfH / 2;
// left arrow
if (this._shelfScroll > 0) {
ctx.beginPath();
ctx.moveTo(padX - 1, arrowY);
ctx.lineTo(padX + 7, arrowY - 8);
ctx.lineTo(padX + 7, arrowY + 8);
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
// right arrow
if (this._shelfScroll < maxScroll) {
const rx = W - padX + 1;
ctx.beginPath();
ctx.moveTo(rx, arrowY);
ctx.lineTo(rx - 8, arrowY - 8);
ctx.lineTo(rx - 8, arrowY + 8);
ctx.closePath();
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fill();
}
ctx.restore();
}
this._shelfKeys = keys;
this._shelfLayout = layoutArr;
this._shelfStartX = padX;
this._shelfBottleW = bW;
this._shelfGap = gapX;
this._shelfBottleY = gridTop;
this._shelfBottleH = bH;
}
_drawDragGhost() {
if (!this._drag) return;
const { ctx } = this;
const { formula, x, y } = this._drag;
const sub = ChemSandboxSim.SUBSTANCES[formula];
if (!sub) return;
ctx.save();
ctx.globalAlpha = 0.7;
// small bottle ghost
ctx.beginPath();
this._roundRect(ctx, x - 18, y - 22, 36, 44, 5);
ctx.fillStyle = 'rgba(75,205,155,0.15)';
ctx.fill();
ctx.strokeStyle = 'rgba(75,205,155,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y - 8, 5, 0, Math.PI * 2);
ctx.fillStyle = sub.color;
ctx.fill();
ctx.font = '9px "JetBrains Mono", monospace';
ctx.fillStyle = '#7BF5A4';
ctx.textAlign = 'center';
ctx.fillText(this._shortFormula(formula), x, y + 10);
ctx.globalAlpha = 1;
ctx.restore();
}
_drawHint() {
const { ctx, W } = this;
const { cy } = this._g;
ctx.save();
ctx.textAlign = 'center';
ctx.font = '14px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillText('Выберите реагенты на полке или панели', W / 2, cy);
ctx.font = '36px serif';
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillText('\u{1F9EA}', W / 2, cy - 30);
ctx.restore();
}
_drawQuizOverlay() {
if (!this._quizTask) return;
const { ctx, W } = this;
const { nt } = this._g;
ctx.save();
ctx.textAlign = 'center';
// question banner at top
const bannerY = Math.max(4, nt - 58);
ctx.fillStyle = 'rgba(155,93,229,0.12)';
ctx.beginPath();
this._roundRect(ctx, W / 2 - 180, bannerY, 360, 24, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(155,93,229,0.30)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.font = 'bold 11px "Manrope", sans-serif';
ctx.fillStyle = '#C9A0FF';
ctx.fillText(this._quizTask.question, W / 2, bannerY + 15);
// score
ctx.font = '9px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.textAlign = 'right';
ctx.fillText(`${this._quizScore}/${this._quizTotal}`, W - 12, bannerY + 14);
// result flash
if (this._quizResult && this._quizResultT > 0) {
const alpha = Math.min(1, this._quizResultT / 0.5);
const isOk = this._quizResult === 'correct';
ctx.textAlign = 'center';
ctx.font = 'bold 18px "Manrope", sans-serif';
ctx.fillStyle = isOk
? `rgba(123,245,164,${alpha * 0.9})`
: `rgba(239,71,111,${alpha * 0.9})`;
ctx.fillText(isOk ? 'Верно!' : 'Неверно', W / 2, bannerY + 50);
if (!isOk && this._quizTask) {
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.45})`;
ctx.fillText('Ответ: ' + this._quizTask.rx.eq, W / 2, bannerY + 65);
}
}
ctx.restore();
}
/* ── Мышь ──────────────────────────────────────────────────── */
_hitShelfCard(mx, my) {
if (!this._shelfLayout) return null;
for (let i = 0; i < this._shelfLayout.length; i++) {
const c = this._shelfLayout[i];
if (mx >= c.x && mx <= c.x + c.w && my >= c.y && my <= c.y + c.h) return i;
}
return null;
}
handleClick(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// check chip "×" buttons first
for (const chip of this._chipLayout) {
if (mx >= chip.xBtnX - 8 && mx <= chip.xBtnX + 8 && my >= chip.y && my <= chip.y + chip.h) {
this.removeFromMix(chip.formula);
return;
}
}
// check shelf cards (2-row grid)
const idx = this._hitShelfCard(mx, my);
if (idx !== null && this._shelfLayout[idx]) {
this.addToMix(this._shelfLayout[idx].formula);
return;
}
}
handleMouseDown(e) {
if (e.button !== 0) return;
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const idx = this._hitShelfCard(mx, my);
if (idx !== null && this._shelfLayout[idx]) {
this._drag = { formula: this._shelfLayout[idx].formula, x: mx, y: my, startX: mx, startY: my };
}
}
handleMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (this._drag) {
this._drag.x = mx;
this._drag.y = my;
return;
}
// hover detection
const idx = this._hitShelfCard(mx, my);
this._shelfHover = idx !== null ? idx : -1;
}
handleMouseUp(e) {
if (!this._drag) return;
const d = this._drag;
this._drag = null;
// if dragged into flask area — add
const { cx, cy, r, nt } = this._g;
const dx = d.x - cx, dy = d.y - cy;
const inFlask = (dy > nt - cy - 20) && (dx * dx + dy * dy < (r + 30) * (r + 30));
const movedEnough = Math.abs(d.x - d.startX) + Math.abs(d.y - d.startY) > 10;
if (inFlask && movedEnough) {
this.addToMix(d.formula);
}
}
handleWheel(e) {
const rect = this.canvas.getBoundingClientRect();
const my = e.clientY - rect.top;
const { shelfY, shelfH } = this._g;
// only scroll when cursor is over shelf area
if (my >= shelfY - 10 && my <= shelfY + shelfH + 10) {
e.preventDefault();
this._shelfScroll += e.deltaY * 0.8;
}
}
handleContextMenu(e) {
e.preventDefault();
this.reset();
}
/* ── Режим заданий (квиз) ──────────────────────────────────── */
startQuiz() {
this._quizMode = true;
this._quizScore = 0;
this._quizTotal = 0;
this.filterCat = 'all'; // show all reagents so quiz tasks are always solvable
this._nextQuizTask();
this._fireQuizInfo();
}
stopQuiz() {
this._quizMode = false;
this._quizTask = null;
this._quizResult = null;
this.reset();
this._fireQuizInfo();
}
_nextQuizTask() {
this.reset();
// pick a random non-indicator, non-"no reaction" reaction
const pool = ChemSandboxSim.REACTIONS.filter(rx =>
rx.type !== 'Индикатор' && rx.type !== 'Нет реакции'
);
const rx = pool[Math.floor(Math.random() * pool.length)];
const questions = this._generateQuestions(rx);
const q = questions[Math.floor(Math.random() * questions.length)];
this._quizTask = { rx, question: q, answer: [...rx.r] };
this._quizResult = null;
this._fireQuizInfo();
}
_generateQuestions(rx) {
const prods = this._productsStr(rx);
const questions = [];
if (rx.fx.precip) {
questions.push(`Получи осадок ${rx.fx.precip.n}`);
}
if (rx.fx.gas) {
questions.push(`Получи газ ${rx.fx.gas}`);
}
questions.push(`Проведи реакцию: ${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 ? 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());
}
}