'use strict'; /* ═══════════════════════════════════════════════════════════════════════ SolutionsSim — «Растворы» 4 sub-modes: calculator, dilution, mixing, solubility curves ═══════════════════════════════════════════════════════════════════════ */ /* ── helper: create element ── */ function _slEl(tag, props) { var el = document.createElement(tag); if (props) { Object.keys(props).forEach(function(k) { if (k === 'style') { el.style.cssText = props[k]; } else if (k === 'textContent') { el.textContent = props[k]; } else if (k === 'innerHTML') { el.innerHTML = props[k]; } else { el[k] = props[k]; } }); } return el; } class SolutionsSim { /* ── Вещества-пресеты ── */ static SUBSTANCES = [ { label: 'NaCl', M: 58.5, color: '#bde0f7' }, { label: 'NaOH', M: 40.0, color: '#a8f0a8' }, { label: 'KOH', M: 56.1, color: '#b8f5b8' }, { label: 'HCl (газ)', M: 36.46, color: '#f5e0a0' }, { label: 'H₂SO₄', M: 98.08, color: '#ffa07a' }, { label: 'HNO₃', M: 63.01, color: '#f5d0a0' }, { label: 'H₃PO₄', M: 97.99, color: '#f0c8a0' }, { label: 'CuSO₄·5H₂O', M: 249.7, color: '#64b8f0' }, { label: 'Глюкоза', M: 180.2, color: '#f0d080' }, { label: 'Сахароза', M: 342.3, color: '#e8d0a0' }, { label: 'Na₂SO₄', M: 142.0, color: '#c8d8f0' }, { label: 'KCl', M: 74.55, color: '#d0f0d0' }, { label: 'CaCl₂', M: 111.0, color: '#f0d8b8' }, { label: 'AlCl₃', M: 133.3, color: '#e8d0c8' }, { label: 'Другое', M: 1.0, color: '#aaaaaa' }, ]; /* ── Кривые растворимости (г/100г H₂O при 0,10,20,30,40,50,60,70,80,90,100 °C) ── */ static SOLUBILITY = [ { label: 'NaCl', color: '#4CC9F0', data: [35.7,35.8,36.0,36.3,36.6,37.0,37.3,37.8,38.4,39.0,39.8] }, { label: 'KNO₃', color: '#FFD166', data: [13.3,20.9,31.6,45.8,63.9,85.5,110,138,169,202,247] }, { label: 'KCl', color: '#06D6E0', data: [27.6,31.0,34.0,37.0,40.0,42.6,45.5,48.3,51.1,54.0,56.7] }, { label: 'Pb(NO₃)₂', color: '#EF476F', data: [37.6,44.3,54.3,64.5,74.5,84.0,93.3,100,109,116,127] }, { label: 'CuSO₄', color: '#9B5DE5', data: [14.3,17.4,20.7,25.0,28.5,33.3,40.2,47.5,55.0,63.4,73.0] }, { label: 'NH₄Cl', color: '#F15BB5', data: [29.4,33.3,37.2,41.4,45.8,50.4,55.2,60.2,65.6,71.3,77.3] }, { label: 'NH₃', color: '#7BF5A4', data: [88.5,70.0,54.0,39.3,26.8,17.0,10.2,6.5,4.3,3.0,2.1] }, { label: 'K₂Cr₂O₇', color: '#FF9F1C', data: [4.7,8.3,12.5,19.0,26.3,35.0,45.6,58.2,73.0,87.0,100] }, ]; constructor(container) { this._container = container; this._mode = 'calc'; // 'calc' | 'dilution' | 'mixing' | 'solubility' this._solEnabled = new Set([0, 1, 2, 3, 4]); // enabled solubility curves this._solT = 20; // calc state this._calc = { m_solute: 58.5, // г m_solution: 500, // г rho: 1.08, // г/мл M: 58.5, // г/моль (NaCl) subIdx: 0, T: 20, }; // dilution state this._dil = { m1: 200, omega1: 20, addWater: 100 }; // mixing state this._mix = { m1: 200, omega1: 20, m2: 300, omega2: 10 }; this._init(); this._setMode('calc'); } /* ── Build root DOM ── */ _init() { var c = this._container; c.innerHTML = ''; c.style.cssText = 'display:flex;flex-direction:column;height:100%;overflow:hidden;background:#0D0D1A;font-family:Manrope,sans-serif;'; // Mode tabs var tabs = _slEl('div', { style: 'flex:0 0 auto;display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.02);', }); var MODES = [ { id: 'calc', label: 'Калькулятор' }, { id: 'dilution', label: 'Разбавление' }, { id: 'mixing', label: 'Смешивание' }, { id: 'solubility', label: 'Растворимость' }, ]; this._tabBtns = {}; MODES.forEach(function(m) { var btn = _slEl('button', { style: 'flex:1;padding:10px 4px;background:none;border:none;border-bottom:2px solid transparent;color:rgba(255,255,255,0.5);font-size:.78rem;font-weight:700;font-family:Manrope,sans-serif;cursor:pointer;transition:color .2s,border-color .2s;', textContent: m.label, }); btn.addEventListener('click', this._setMode.bind(this, m.id)); this._tabBtns[m.id] = btn; tabs.appendChild(btn); }.bind(this)); c.appendChild(tabs); // Content area this._content = _slEl('div', { style: 'flex:1 1 auto;display:flex;min-height:0;overflow:hidden;' }); c.appendChild(this._content); // Canvas for visualisation (shared) this._canvas = document.createElement('canvas'); this._ctx2d = this._canvas.getContext('2d'); this._raf = null; if (window.ResizeObserver) { this._ro = new ResizeObserver(function() { this._fitCanvas(); this._drawViz(); }.bind(this)); } } /* ── Switch mode ── */ _setMode(mode) { this._mode = mode; Object.keys(this._tabBtns).forEach(function(id) { var btn = this._tabBtns[id]; btn.style.color = id === mode ? '#fff' : 'rgba(255,255,255,0.45)'; btn.style.borderColor = id === mode ? 'var(--violet,#9B5DE5)' : 'transparent'; btn.style.background = id === mode ? 'rgba(155,93,229,0.08)' : 'none'; }.bind(this)); if (this._ro && this._canvas.parentElement) { this._ro.unobserve(this._canvas); } this._content.innerHTML = ''; if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } switch (mode) { case 'calc': this._buildCalc(); break; case 'dilution': this._buildDilution(); break; case 'mixing': this._buildMixing(); break; case 'solubility':this._buildSolubility(); break; } } /* ════════════════════════════════════════════════════════ MODE 1 — КАЛЬКУЛЯТОР ════════════════════════════════════════════════════════ */ _buildCalc() { var self = this; var c = this._content; c.style.flexDirection = ''; // Left panel var left = _slEl('div', { style: 'flex:0 0 270px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);', }); // Section header function sHead(txt) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: txt }); } // Substance selector left.appendChild(sHead('Вещество (M, г/моль)')); var subWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' }); var subSel = document.createElement('select'); subSel.style.cssText = 'flex:1;background:#1a1a2e;color:#fff;border:1px solid rgba(255,255,255,0.15);border-radius:6px;padding:5px 8px;font-size:.78rem;font-family:Manrope,sans-serif;cursor:pointer;'; SolutionsSim.SUBSTANCES.forEach(function(s, i) { var opt = document.createElement('option'); opt.value = i; opt.textContent = s.label + ' = ' + s.M; if (i === self._calc.subIdx) opt.selected = true; subSel.appendChild(opt); }); subSel.addEventListener('change', function() { self._calc.subIdx = +subSel.value; var sub = SolutionsSim.SUBSTANCES[self._calc.subIdx]; self._calc.M = sub.M; if (mInput) mInput.value = sub.M; if (window.LabFX) LabFX.sound.play('click', { pitch: 1.1 }); self._recalcCalc('M'); }); subWrap.appendChild(subSel); left.appendChild(subWrap); // Slider row function sliderRow(label, id, min, max, step, val, unit, color, onInput) { var row = _slEl('div', { style: 'margin-bottom:10px;' }); var labelRow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); labelRow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); var valSpan = _slEl('span', { id: id + '-val', style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); labelRow.appendChild(valSpan); row.appendChild(labelRow); var sl = document.createElement('input'); sl.type = 'range'; sl.id = id + '-sl'; sl.min = min; sl.max = max; sl.step = step; sl.value = val; sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; sl.addEventListener('input', function() { if (window.LabFX) LabFX.sound.play('click', { pitch: 1.0, volume: 0.25 }); onInput(+sl.value); valSpan.textContent = (+sl.value).toFixed(step < 1 ? 2 : 0) + ' ' + unit; }); row.appendChild(sl); // number input var ni = document.createElement('input'); ni.type = 'number'; ni.min = min; ni.max = max; ni.step = step; ni.value = val; ni.style.cssText = 'width:100%;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;margin-top:2px;box-sizing:border-box;'; ni.addEventListener('change', function() { var v = Math.min(max, Math.max(min, +ni.value || min)); sl.value = v; ni.value = v; valSpan.textContent = v.toFixed(step < 1 ? 2 : 0) + ' ' + unit; onInput(v); }); row.appendChild(ni); return { row: row, sl: sl, ni: ni, valSpan: valSpan }; } left.appendChild(sHead('Входные параметры')); var mSoluteCtrl = sliderRow('mв — масса растворённого', 'sl-calc-ms', 0, 500, 1, self._calc.m_solute, 'г', '#4CC9F0', function(v) { self._calc.m_solute = v; self._recalcCalc('m_solute'); }); left.appendChild(mSoluteCtrl.row); var mSolCtrl = sliderRow('mр-ра — масса раствора', 'sl-calc-mr', 1, 1000, 1, self._calc.m_solution, 'г', '#FFD166', function(v) { self._calc.m_solution = Math.max(v, self._calc.m_solute + 0.01); self._recalcCalc('m_solution'); }); left.appendChild(mSolCtrl.row); var rhoCtrl = sliderRow('ρ — плотность раствора', 'sl-calc-rho', 0.8, 2.0, 0.01, self._calc.rho, 'г/мл', '#9B5DE5', function(v) { self._calc.rho = v; self._recalcCalc('rho'); }); left.appendChild(rhoCtrl.row); left.appendChild(sHead('Молярная масса')); var mWrap = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:10px;' }); mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'M =' })); var mInput = document.createElement('input'); mInput.type = 'number'; mInput.min = 1; mInput.max = 500; mInput.step = 0.1; mInput.value = self._calc.M; mInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 8px;font-size:.78rem;'; mInput.addEventListener('change', function() { self._calc.M = Math.max(1, Math.min(500, +mInput.value || 1)); mInput.value = self._calc.M; self._recalcCalc('M'); }); mWrap.appendChild(mInput); mWrap.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.5);', textContent: 'г/моль' })); left.appendChild(mWrap); left.appendChild(sHead('Температура')); var tCtrl = sliderRow('T — температура', 'sl-calc-t', 0, 100, 1, self._calc.T, '°C', '#F15BB5', function(v) { self._calc.T = v; self._recalcCalc('T'); }); left.appendChild(tCtrl.row); c.appendChild(left); // Center — canvas visualization var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;align-items:stretch;min-width:0;position:relative;' }); this._canvas.style.cssText = 'flex:1 1 auto;width:100%;height:100%;display:block;'; centerWrap.appendChild(this._canvas); c.appendChild(centerWrap); // Right panel — results var right = _slEl('div', { style: 'flex:0 0 220px;display:flex;flex-direction:column;gap:0;overflow-y:auto;padding:12px 14px;border-left:1px solid rgba(255,255,255,0.07);', }); right.appendChild(sHead('Вычисленные значения')); this._calcResults = right; c.appendChild(right); // attach ResizeObserver if (this._ro) { this._ro.observe(this._canvas); } requestAnimationFrame(function() { self._fitCanvas(); self._recalcCalc('init'); }); } _recalcCalc(changed) { var s = this._calc; if (s.m_solute > s.m_solution) s.m_solution = s.m_solute + 0.01; var omega = s.m_solution > 0 ? (s.m_solute / s.m_solution) * 100 : 0; var m_water = s.m_solution - s.m_solute; var V_liters = (s.m_solution / s.rho) / 1000; // л var V_ml = s.m_solution / s.rho; // мл var nu = s.M > 0 ? s.m_solute / s.M : 0; // моль var cM = V_liters > 0 ? nu / V_liters : 0; // моль/л // норм. концентрация — упрощённо, как молярная для однозарядных var cN = cM; this._calcState = { omega, m_water, V_ml, V_liters, nu, cM, cN }; var r = this._calcResults; if (!r) return; function resLine(label, value, unit, color, formula) { var row = _slEl('div', { style: 'margin-bottom:10px;padding:8px;background:rgba(255,255,255,0.04);border-radius:8px;border-left:3px solid ' + color + ';' }); row.appendChild(_slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.5);margin-bottom:2px;', innerHTML: label })); row.appendChild(_slEl('div', { style: 'font-size:1.1rem;font-weight:800;color:' + color + ';', textContent: value + ' ' + unit })); if (formula) { var fd = _slEl('div', { style: 'margin-top:4px;font-size:.7rem;color:rgba(255,255,255,0.35);' }); fd.setAttribute('data-formula', formula); row.appendChild(fd); } return row; } r.innerHTML = ''; r.appendChild(_slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: 'Вычисленные значения' })); r.appendChild(resLine('ω — массовая доля', omega.toFixed(2), '%', '#4CC9F0', '\\omega = \\frac{m_в}{m_{р-ра}} \\times 100\\%')); r.appendChild(resLine('mводы — масса воды', m_water.toFixed(1), 'г', '#06D6E0', '')); r.appendChild(resLine('V — объём раствора', V_ml.toFixed(1), 'мл', '#FFD166', 'V = \\frac{m_{р-ра}}{\\rho}')); r.appendChild(resLine('ν — количество вещества', nu.toFixed(4), 'моль', '#9B5DE5', '\\nu = \\frac{m_в}{M}')); r.appendChild(resLine('CМ — молярная', cM.toFixed(4), 'моль/л', '#F15BB5', 'C_M = \\frac{\\nu}{V}')); r.appendChild(resLine('CН — нормальность', cN.toFixed(4), 'моль-экв/л', '#EF476F', '')); // Render KaTeX formulas r.querySelectorAll('[data-formula]').forEach(function(el) { if (window.katex && el.getAttribute('data-formula')) { try { katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false }); } catch(e) { /* ignore */ } } }); if (window.LabFX && changed !== 'init') { LabFX.sound.play('chime', { pitch: 1.4, volume: 0.3 }); } this._drawViz(); } /* ════════════════════════════════════════════════════════ MODE 2 — РАЗБАВЛЕНИЕ ════════════════════════════════════════════════════════ */ _buildDilution() { var self = this; var c = this._content; c.style.flexDirection = ''; // Left controls var left = _slEl('div', { style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);', }); function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); } function numRow(label, val, min, max, step, unit, color, cb) { var row = _slEl('div', { style: 'margin-bottom:10px;' }); var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); lrow.appendChild(vs); row.appendChild(lrow); var sl = document.createElement('input'); sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val; sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; sl.addEventListener('input', function() { vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit; if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); cb(+sl.value); }); row.appendChild(sl); return { row, sl, vs }; } left.appendChild(sh('Исходный раствор')); numRow('m₁ — масса раствора', self._dil.m1, 50, 500, 1, 'г', '#FFD166', function(v) { self._dil.m1 = v; self._recalcDil(); }).row; left.appendChild(left.lastChild); var dil_m1 = left.lastChild; numRow('ω₁ — концентрация', self._dil.omega1, 1, 80, 0.5, '%', '#4CC9F0', function(v) { self._dil.omega1 = v; self._recalcDil(); }).row; left.appendChild(left.lastChild); left.appendChild(sh('Добавляем воду')); numRow('Vводы — объём воды', self._dil.addWater, 0, 1000, 1, 'мл', '#06D6E0', function(v) { self._dil.addWater = v; if (window.LabFX && v > 0) LabFX.sound.play('pour', { volume: 0.4 }); self._recalcDil(); }).row; left.appendChild(left.lastChild); left.appendChild(sh('Результат')); this._dilResultsEl = _slEl('div', {}); left.appendChild(this._dilResultsEl); c.appendChild(left); // Center — canvas var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; centerWrap.appendChild(this._canvas); c.appendChild(centerWrap); if (this._ro) { this._ro.observe(this._canvas); } requestAnimationFrame(function() { self._fitCanvas(); self._recalcDil(); }); } _recalcDil() { var d = this._dil; var m_solute = d.m1 * d.omega1 / 100; var m_new = d.m1 + d.addWater; // плотность воды = 1 г/мл var omega2 = m_new > 0 ? (m_solute / m_new) * 100 : 0; this._dilState = { m_solute, m_new, omega2 }; var r = this._dilResultsEl; if (!r) return; r.innerHTML = ''; function valLine(label, val, unit, color) { var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' }); el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label })); el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit })); return el; } r.appendChild(valLine('mв = const', m_solute.toFixed(2), 'г', '#4CC9F0')); r.appendChild(valLine('mр-ра новая', m_new.toFixed(1), 'г', '#FFD166')); r.appendChild(valLine('ω₂ (новая)', omega2.toFixed(3), '%', '#7BF5A4')); if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.25 }); this._drawViz(); } /* ════════════════════════════════════════════════════════ MODE 3 — СМЕШИВАНИЕ ════════════════════════════════════════════════════════ */ _buildMixing() { var self = this; var c = this._content; c.style.flexDirection = ''; var left = _slEl('div', { style: 'flex:0 0 260px;display:flex;flex-direction:column;overflow-y:auto;padding:14px;border-right:1px solid rgba(255,255,255,0.07);', }); function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:8px 0 6px;', textContent: t }); } function addSl(label, val, min, max, step, unit, color, cb) { var row = _slEl('div', { style: 'margin-bottom:10px;' }); var lrow = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); lrow.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', innerHTML: label })); var vs = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:' + color + ';', textContent: val + ' ' + unit }); lrow.appendChild(vs); row.appendChild(lrow); var sl = document.createElement('input'); sl.type = 'range'; sl.min = min; sl.max = max; sl.step = step; sl.value = val; sl.style.cssText = 'width:100%;accent-color:' + color + ';cursor:pointer;'; sl.addEventListener('input', function() { vs.textContent = (+sl.value).toFixed(step < 1 ? 1 : 0) + ' ' + unit; if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); cb(+sl.value); }); row.appendChild(sl); left.appendChild(row); } left.appendChild(sh('Раствор 1')); addSl('m₁ — масса', self._mix.m1, 10, 500, 1, 'г', '#4CC9F0', function(v) { self._mix.m1 = v; self._recalcMix(); }); addSl('ω₁ — концентрация', self._mix.omega1, 0, 90, 0.5, '%', '#4CC9F0', function(v) { self._mix.omega1 = v; self._recalcMix(); }); left.appendChild(sh('Раствор 2')); addSl('m₂ — масса', self._mix.m2, 10, 500, 1, 'г', '#FFD166', function(v) { self._mix.m2 = v; self._recalcMix(); }); addSl('ω₂ — концентрация', self._mix.omega2, 0, 90, 0.5, '%', '#FFD166', function(v) { self._mix.omega2 = v; self._recalcMix(); }); left.appendChild(sh('Результат')); this._mixResultsEl = _slEl('div', {}); left.appendChild(this._mixResultsEl); c.appendChild(left); var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; centerWrap.appendChild(this._canvas); c.appendChild(centerWrap); if (this._ro) { this._ro.observe(this._canvas); } requestAnimationFrame(function() { self._fitCanvas(); self._recalcMix(); }); } _recalcMix() { var x = this._mix; var m3 = x.m1 + x.m2; var omega3 = m3 > 0 ? (x.m1 * x.omega1 + x.m2 * x.omega2) / m3 : 0; this._mixState = { m3, omega3 }; var r = this._mixResultsEl; if (!r) return; r.innerHTML = ''; function valLine(label, val, unit, color) { var el = _slEl('div', { style: 'display:flex;justify-content:space-between;padding:6px 10px;background:rgba(255,255,255,0.04);border-radius:7px;margin-bottom:6px;' }); el.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.6);', innerHTML: label })); el.appendChild(_slEl('span', { style: 'font-size:.82rem;font-weight:800;color:' + color + ';', textContent: val + ' ' + unit })); return el; } r.appendChild(valLine('m₃ = m₁ + m₂', m3.toFixed(1), 'г', '#7BF5A4')); r.appendChild(valLine('ω₃ — итоговая', omega3.toFixed(3), '%', '#F15BB5')); // правило рычага var fd = _slEl('div', { style: 'margin-top:10px;padding:8px;background:rgba(155,93,229,0.08);border-radius:8px;font-size:.75rem;color:rgba(255,255,255,0.5);' }); fd.appendChild(_slEl('div', { style: 'margin-bottom:4px;color:rgba(155,93,229,0.9);font-weight:700;font-size:.72rem;', textContent: 'Правило рычага' })); var formulaEl = _slEl('div', {}); formulaEl.setAttribute('data-formula', 'm_1 \\cdot \\omega_1 + m_2 \\cdot \\omega_2 = m_3 \\cdot \\omega_3'); fd.appendChild(formulaEl); r.appendChild(fd); if (window.katex) { r.querySelectorAll('[data-formula]').forEach(function(el) { try { katex.render(el.getAttribute('data-formula'), el, { throwOnError: false, displayMode: false }); } catch(e) {} }); } if (window.LabFX) LabFX.sound.play('pour', { volume: 0.5 }); this._drawViz(); } /* ════════════════════════════════════════════════════════ MODE 4 — КРИВЫЕ РАСТВОРИМОСТИ ════════════════════════════════════════════════════════ */ _buildSolubility() { var self = this; var c = this._content; c.style.flexDirection = ''; // Left panel var left = _slEl('div', { style: 'flex:0 0 220px;display:flex;flex-direction:column;overflow-y:auto;padding:12px 14px;border-right:1px solid rgba(255,255,255,0.07);', }); function sh(t) { return _slEl('div', { style: 'font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:rgba(155,93,229,0.8);margin:10px 0 6px;', textContent: t }); } left.appendChild(sh('Вещества')); SolutionsSim.SOLUBILITY.forEach(function(s, idx) { var row = _slEl('div', { style: 'display:flex;align-items:center;gap:8px;margin-bottom:7px;cursor:pointer;' }); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = self._solEnabled.has(idx); cb.style.cssText = 'accent-color:' + s.color + ';width:14px;height:14px;cursor:pointer;'; cb.addEventListener('change', function() { if (cb.checked) { self._solEnabled.add(idx); } else { self._solEnabled.delete(idx); } if (window.LabFX) LabFX.sound.play('click', { pitch: 0.9 }); self._drawViz(); }); var colorDot = _slEl('span', { style: 'width:10px;height:10px;border-radius:50%;background:' + s.color + ';flex-shrink:0;display:inline-block;' }); row.appendChild(cb); row.appendChild(colorDot); row.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.8);', textContent: s.label })); left.appendChild(row); }); left.appendChild(sh('Температура')); var tRow = _slEl('div', { style: 'margin-bottom:10px;' }); var tRowL = _slEl('div', { style: 'display:flex;justify-content:space-between;margin-bottom:3px;' }); tRowL.appendChild(_slEl('span', { style: 'font-size:.78rem;color:rgba(255,255,255,0.7);', textContent: 'T =' })); var tValSpan = _slEl('span', { style: 'font-size:.78rem;font-weight:700;color:#F15BB5;', textContent: self._solT + ' °C' }); tRowL.appendChild(tValSpan); tRow.appendChild(tRowL); var tSl = document.createElement('input'); tSl.type = 'range'; tSl.min = 0; tSl.max = 100; tSl.step = 1; tSl.value = self._solT; tSl.style.cssText = 'width:100%;accent-color:#F15BB5;cursor:pointer;'; tSl.addEventListener('input', function() { self._solT = +tSl.value; tValSpan.textContent = self._solT + ' °C'; if (window.LabFX) LabFX.sound.play('click', { volume: 0.2 }); self._drawViz(); }); tRow.appendChild(tSl); left.appendChild(tRow); // Перекристаллизация задача left.appendChild(sh('Задача: перекристаллизация')); var kno3Hint = _slEl('div', { style: 'font-size:.72rem;color:rgba(255,255,255,0.55);margin-bottom:8px;line-height:1.5;', textContent: 'Насыщенный раствор KNO₃ при 80°C охладили до 20°C.' }); left.appendChild(kno3Hint); var crystRow = _slEl('div', { style: 'display:flex;gap:6px;align-items:center;margin-bottom:8px;' }); crystRow.appendChild(_slEl('span', { style: 'font-size:.75rem;color:rgba(255,255,255,0.6);', textContent: 'Масса р-ра (г):' })); var crystInput = document.createElement('input'); crystInput.type = 'number'; crystInput.min = 10; crystInput.max = 10000; crystInput.step = 10; crystInput.value = 200; crystInput.style.cssText = 'flex:1;background:#111122;color:#fff;border:1px solid rgba(255,255,255,0.12);border-radius:5px;padding:4px 6px;font-size:.75rem;'; crystRow.appendChild(crystInput); left.appendChild(crystRow); var crystBtn = _slEl('button', { style: 'width:100%;padding:8px;border-radius:7px;border:none;background:linear-gradient(135deg,#9B5DE5,#4CC9F0);color:#fff;font-size:.78rem;font-weight:700;cursor:pointer;', textContent: 'Рассчитать', }); this._crystAnswer = _slEl('div', { style: 'margin-top:8px;font-size:.75rem;line-height:1.5;color:#7BF5A4;', textContent: '' }); crystBtn.addEventListener('click', function() { var mSol = +crystInput.value || 200; // S at 80°C = 169 g/100g H2O, at 20°C = 31.6 g/100g H2O var S80 = 169, S20 = 31.6; var kno3Index = 1; // KNO₃ is index 1 in SOLUBILITY // Насыщенный раствор при 80°C: x г KNO3 на 100 г воды // m_r = m_KNO3 + m_H2O => m_KNO3 = mSol * S80 / (100 + S80) var m_kno3 = mSol * S80 / (100 + S80); var m_h2o = mSol - m_kno3; // при 20°C в m_h2o г воды растворится не более S20 * m_h2o / 100 var dissolved20 = S20 * m_h2o / 100; var precipitated = m_kno3 - dissolved20; precipitated = Math.max(0, precipitated); self._crystAnswer.textContent = 'KNO₃ в р-ре: ' + m_kno3.toFixed(1) + ' г\n' + 'H₂O: ' + m_h2o.toFixed(1) + ' г\n' + 'Выпало осадка: ' + precipitated.toFixed(1) + ' г'; if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.0, volume: 0.5 }); }); left.appendChild(crystBtn); left.appendChild(this._crystAnswer); c.appendChild(left); // Center — canvas var centerWrap = _slEl('div', { style: 'flex:1 1 auto;display:flex;flex-direction:column;min-width:0;' }); this._canvas.style.cssText = 'flex:1 1 auto;width:100%;display:block;'; centerWrap.appendChild(this._canvas); c.appendChild(centerWrap); if (this._ro) { this._ro.observe(this._canvas); } requestAnimationFrame(function() { self._fitCanvas(); self._drawViz(); }); } /* ════════════════════════════════════════════════════════ CANVAS — fitCanvas + drawViz dispatcher ════════════════════════════════════════════════════════ */ _fitCanvas() { var cv = this._canvas; if (!cv.parentElement) return; var r = cv.parentElement.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return; cv.width = r.width * (window.devicePixelRatio || 1); cv.height = r.height * (window.devicePixelRatio || 1); cv.style.width = r.width + 'px'; cv.style.height = r.height + 'px'; this._ctx2d.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0); } _drawViz() { switch (this._mode) { case 'calc': this._drawCalcViz(); break; case 'dilution': this._drawDilViz(); break; case 'mixing': this._drawMixViz(); break; case 'solubility': this._drawSolubility(); break; } } /* ── Draw helper: gradient beaker ── */ _drawBeaker(ctx, x, y, w, h, fillH, color, label) { var bw = w * 0.55; // beaker width var bh = h * 0.72; // beaker body height var bx = x + (w - bw) / 2; var by = y + h * 0.1; // beaker body (trapezoid-ish, bottom wider) ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(bx + bw * 0.1, by); ctx.lineTo(bx + bw * 0.9, by); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh); ctx.closePath(); ctx.stroke(); // liquid fill var fillRatio = Math.min(1, Math.max(0, fillH)); var fillY = by + bh * (1 - fillRatio); ctx.save(); ctx.beginPath(); ctx.rect(bx - 2, fillY, bw + 4, bh - (fillY - by)); ctx.clip(); var grad = ctx.createLinearGradient(bx, fillY, bx + bw, fillY + bh); grad.addColorStop(0, color.replace(')', ',0.85)').replace('rgb', 'rgba')); grad.addColorStop(1, color.replace(')', ',0.5)').replace('rgb', 'rgba')); ctx.fillStyle = color + 'cc'; ctx.fill(); ctx.restore(); // label ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.font = 'bold 13px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText(label, x + w / 2, y + h + 16); } /* ── Calc visualization: beaker + percentage ── */ _drawCalcViz() { var cv = this._canvas; var ctx = this._ctx2d; var W = cv.width / (window.devicePixelRatio || 1); var H = cv.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, W, H); if (!this._calcState) return; var st = this._calcState; var omega = st.omega; var sub = SolutionsSim.SUBSTANCES[this._calc.subIdx]; // draw background gradient var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W, H) * 0.7); bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); // Beaker var bW = Math.min(W * 0.45, 220); var bH = Math.min(H * 0.7, 320); var bX = (W - bW) / 2; var bY = H * 0.08; var bw = bW * 0.55; var bh = bH * 0.72; var bx = bX + (bW - bw) / 2; var by = bY + bH * 0.1; // outer glass ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(bx + bw * 0.08, by); ctx.lineTo(bx + bw * 0.92, by); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh); ctx.closePath(); ctx.stroke(); // graduation marks ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; for (var i = 1; i <= 4; i++) { var gy = by + bh * (i / 5); ctx.beginPath(); ctx.moveTo(bx + 4, gy); ctx.lineTo(bx + 12, gy); ctx.stroke(); } // liquid fill var fillRatio = Math.min(1, Math.max(0.05, (this._calc.m_solution / 1000) * 0.85)); var liquidTop = by + bh * (1 - fillRatio); ctx.save(); ctx.beginPath(); ctx.moveTo(bx + bw * 0.08, by); ctx.lineTo(bx + bw * 0.92, by); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh); ctx.closePath(); ctx.clip(); var lgrad = ctx.createLinearGradient(bx, liquidTop, bx + bw, by + bh); var baseColor = sub.color || '#4CC9F0'; lgrad.addColorStop(0, baseColor + 'bb'); lgrad.addColorStop(1, baseColor + '77'); ctx.fillStyle = lgrad; ctx.beginPath(); ctx.rect(bx - 5, liquidTop, bw + 10, bh - (liquidTop - by) + 5); ctx.fill(); // concentration ripple effect on surface ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(bx + 4, liquidTop); ctx.bezierCurveTo(bx + bw * 0.25, liquidTop - 4, bx + bw * 0.75, liquidTop + 4, bx + bw - 4, liquidTop); ctx.stroke(); ctx.restore(); // % label inside beaker ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = 'bold 28px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(omega.toFixed(1) + '%', bx + bw / 2, liquidTop + (by + bh - liquidTop) * 0.45); // substance name below ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fillText(sub.label, bx + bw / 2, by + bh + 22); // beaker label top ctx.font = 'bold 11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillText('Мерный стакан', bx + bw / 2, by - 10); ctx.textBaseline = 'alphabetic'; // formula annotation var fY = by + bh + 48; ctx.font = '11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.textAlign = 'center'; ctx.fillText('ω = ' + omega.toFixed(2) + '% · ν = ' + this._calcState.nu.toFixed(3) + ' моль · Cм = ' + this._calcState.cM.toFixed(3) + ' моль/л', W / 2, fY); } /* ── Dilution visualization ── */ _drawDilViz() { var cv = this._canvas; var ctx = this._ctx2d; var W = cv.width / (window.devicePixelRatio || 1); var H = cv.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, W, H); if (!this._dilState) return; var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); var d = this._dil; var st = this._dilState; var bH = Math.min(H * 0.65, 280); var bW = 100; var gap = 60; var totalW = bW * 2 + gap + 80; var startX = (W - totalW) / 2; var bY = H * 0.12; function drawSimpleBeaker(cx, cy, bw, bh, fill, color, label, omegaLabel) { // glass ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + bw, cy); ctx.lineTo(cx + bw, cy + bh); ctx.lineTo(cx, cy + bh); ctx.closePath(); ctx.stroke(); // liquid var lh = Math.max(4, bh * fill); var ly = cy + bh - lh; ctx.save(); ctx.beginPath(); ctx.rect(cx + 1, cy + 1, bw - 2, bh - 2); ctx.clip(); var g = ctx.createLinearGradient(cx, ly, cx + bw, cy + bh); g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '77'); ctx.fillStyle = g; ctx.fillRect(cx + 1, ly, bw - 2, lh); ctx.restore(); // omega text ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = 'bold 14px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(omegaLabel, cx + bw / 2, ly + lh * 0.45); // label ctx.font = '12px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textBaseline = 'alphabetic'; ctx.fillText(label, cx + bw / 2, cy + bh + 20); } // Original beaker var fill1 = Math.min(0.9, Math.max(0.1, d.m1 / 500)); drawSimpleBeaker(startX, bY, bW, bH, fill1, '#4CC9F0', 'Исходный', d.omega1.toFixed(1) + '%'); // Arrow + "добавили воды" var arrowX = startX + bW + 10; var arrowY = bY + bH / 2; ctx.strokeStyle = 'rgba(6,214,224,0.7)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(arrowX, arrowY); ctx.lineTo(arrowX + gap - 10, arrowY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(arrowX + gap - 10, arrowY - 6); ctx.lineTo(arrowX + gap, arrowY); ctx.lineTo(arrowX + gap - 10, arrowY + 6); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText('+' + d.addWater + ' мл', arrowX + gap / 2, arrowY - 10); ctx.fillText('H₂O', arrowX + gap / 2, arrowY + 20); // New beaker (lighter color) var colorIntensity = Math.max(0.15, st.omega2 / Math.max(1, d.omega1)); var r2 = Math.round(76 + (1 - colorIntensity) * 179); var g2 = Math.round(201 + (1 - colorIntensity) * 54); var b2 = Math.round(240 + 0); var c2 = 'rgb(' + r2 + ',' + Math.min(255, g2) + ',' + Math.min(255, b2) + ')'; var fill2 = Math.min(0.9, Math.max(0.1, st.m_new / 600)); drawSimpleBeaker(startX + bW + gap, bY, bW, bH, fill2, c2, 'Разбавленный', st.omega2.toFixed(2) + '%'); // note ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.fillText('m_в = const = ' + (d.m1 * d.omega1 / 100).toFixed(1) + ' г', W / 2, bY + bH + 50); } /* ── Mixing visualization ── */ _drawMixViz() { var cv = this._canvas; var ctx = this._ctx2d; var W = cv.width / (window.devicePixelRatio || 1); var H = cv.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, W, H); if (!this._mixState) return; var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); var x = this._mix; var st = this._mixState; var bH = Math.min(H * 0.55, 240); var bW = 90; var bY = H * 0.1; var totalW3 = bW * 3 + 140; var sx = (W - totalW3) / 2; function drawBk(bx, by, bw, bh, fill, color, lbl, pct) { ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + bw, by); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx, by + bh); ctx.closePath(); ctx.stroke(); var lh = Math.max(4, bh * fill); var ly = by + bh - lh; ctx.save(); ctx.beginPath(); ctx.rect(bx+1, by+1, bw-2, bh-2); ctx.clip(); var g = ctx.createLinearGradient(bx, ly, bx + bw, by + bh); g.addColorStop(0, color + 'cc'); g.addColorStop(1, color + '66'); ctx.fillStyle = g; ctx.fillRect(bx+1, ly, bw-2, lh); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.font = 'bold 13px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(pct, bx + bw/2, ly + lh * 0.45); ctx.font = '11px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textBaseline = 'alphabetic'; ctx.fillText(lbl, bx + bw/2, by + bh + 18); } var fill1 = Math.min(0.88, Math.max(0.08, x.m1 / 500)); var fill2 = Math.min(0.88, Math.max(0.08, x.m2 / 500)); var fill3 = Math.min(0.88, Math.max(0.08, st.m3 / 700)); drawBk(sx, bY, bW, bH, fill1, '#4CC9F0', 'Р-р 1', x.omega1.toFixed(1) + '%'); // + sign ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = 'bold 24px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('+', sx + bW + 28, bY + bH / 2); drawBk(sx + bW + 55, bY, bW, bH, fill2, '#FFD166', 'Р-р 2', x.omega2.toFixed(1) + '%'); // = sign ctx.font = 'bold 24px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('=', sx + bW * 2 + 55 + 30, bY + bH / 2); // mixed color var r0 = 76, g0 = 201, b0 = 240; // #4CC9F0 var r1x = 255, g1x = 209, b1x = 102; // #FFD166 var t = x.m1 / Math.max(1, x.m1 + x.m2); var mixR = Math.round(r0 * t + r1x * (1 - t)); var mixG = Math.round(g0 * t + g1x * (1 - t)); var mixB = Math.round(b0 * t + b1x * (1 - t)); var mixColor = 'rgb(' + mixR + ',' + mixG + ',' + mixB + ')'; drawBk(sx + bW * 2 + 55 + 55, bY, bW, bH, fill3, mixColor, 'Смесь', st.omega3.toFixed(2) + '%'); ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText('m₃ = ' + st.m3.toFixed(0) + ' г · ω₃ = ' + st.omega3.toFixed(3) + '%', W / 2, bY + bH + 46); } /* ── Solubility curve chart ── */ _drawSolubility() { var cv = this._canvas; var ctx = this._ctx2d; var W = cv.width / (window.devicePixelRatio || 1); var H = cv.height / (window.devicePixelRatio || 1); ctx.clearRect(0, 0, W, H); var bg = ctx.createRadialGradient(W/2, H/2, 10, W/2, H/2, Math.max(W,H)*0.7); bg.addColorStop(0, '#14142a'); bg.addColorStop(1, '#0D0D1A'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); var pad = { l: 56, r: 24, t: 20, b: 46 }; var cW = W - pad.l - pad.r; var cH = H - pad.t - pad.b; var temps = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; var maxS = 0; SolutionsSim.SOLUBILITY.forEach(function(s, i) { if (this._solEnabled.has(i)) { s.data.forEach(function(v) { if (v > maxS) maxS = v; }); } }.bind(this)); maxS = maxS > 0 ? Math.ceil(maxS / 50) * 50 : 250; function toX(T) { return pad.l + (T / 100) * cW; } function toY(S) { return pad.t + cH - (S / maxS) * cH; } // Grid ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1; for (var gs = 0; gs <= maxS; gs += 50) { ctx.beginPath(); ctx.moveTo(pad.l, toY(gs)); ctx.lineTo(pad.l + cW, toY(gs)); ctx.stroke(); } for (var gt = 0; gt <= 100; gt += 20) { ctx.beginPath(); ctx.moveTo(toX(gt), pad.t); ctx.lineTo(toX(gt), pad.t + cH); ctx.stroke(); } // Axes ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(pad.l, pad.t); ctx.lineTo(pad.l, pad.t + cH); ctx.lineTo(pad.l + cW, pad.t + cH); ctx.stroke(); // Axis labels ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '10px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; temps.forEach(function(t) { if (t % 20 === 0) ctx.fillText(t + '°', toX(t), pad.t + cH + 6); }); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (var ys = 0; ys <= maxS; ys += 50) { ctx.fillText(ys, pad.l - 6, toY(ys)); } ctx.save(); ctx.translate(pad.l - 40, pad.t + cH / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '11px Manrope,sans-serif'; ctx.fillText('S, г / 100г H₂O', 0, 0); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.font = '11px Manrope,sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText('T, °C', pad.l + cW / 2, H - 4); // Curves SolutionsSim.SOLUBILITY.forEach(function(s, idx) { if (!this._solEnabled.has(idx)) return; ctx.strokeStyle = s.color; ctx.lineWidth = 2.2; ctx.lineJoin = 'round'; ctx.beginPath(); s.data.forEach(function(val, i) { var px = toX(temps[i]); var py = toY(val); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); }); ctx.stroke(); // End label var last = s.data[s.data.length - 1]; var lx = toX(100) + 4; var ly = toY(last); ctx.fillStyle = s.color; ctx.font = 'bold 10px Manrope,sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(s.label, lx, ly); }.bind(this)); // Current temperature line + dots var Tcur = this._solT; ctx.strokeStyle = '#F15BB5'; ctx.lineWidth = 1.5; ctx.setLineDash([5, 3]); ctx.beginPath(); ctx.moveTo(toX(Tcur), pad.t); ctx.lineTo(toX(Tcur), pad.t + cH); ctx.stroke(); ctx.setLineDash([]); // Intersection dots + values SolutionsSim.SOLUBILITY.forEach(function(s, idx) { if (!this._solEnabled.has(idx)) return; // interpolate S at Tcur var ti = Tcur / 10; var lo = Math.floor(ti); var hi = Math.min(lo + 1, 10); var frac = ti - lo; var Sval = s.data[lo] * (1 - frac) + s.data[hi] * frac; var dotX = toX(Tcur); var dotY = toY(Sval); ctx.beginPath(); ctx.arc(dotX, dotY, 5, 0, Math.PI * 2); ctx.fillStyle = s.color; ctx.fill(); ctx.strokeStyle = '#0D0D1A'; ctx.lineWidth = 1.5; ctx.stroke(); // value popup ctx.fillStyle = s.color; ctx.font = 'bold 10px Manrope,sans-serif'; ctx.textAlign = dotX > W * 0.6 ? 'right' : 'left'; ctx.textBaseline = 'middle'; var offset = dotX > W * 0.6 ? -8 : 8; ctx.fillText(Sval.toFixed(1), dotX + offset, dotY - 10); }.bind(this)); ctx.textAlign = 'left'; } /* ── stop for closeSim() ── */ stop() { if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; } if (this._ro && this._canvas.parentElement) { this._ro.unobserve(this._canvas); } } /* ── fit ── */ fit() { this._fitCanvas(); this._drawViz(); } } if (typeof module !== 'undefined') module.exports = SolutionsSim; /* ─── lab UI init ─────────────────────────────────── */ var _solutionsSim = null; function _openSolutions() { document.getElementById('sim-topbar-title').textContent = 'Растворы'; _simShow('sim-solutions'); requestAnimationFrame(function() { requestAnimationFrame(function() { var container = document.getElementById('solutions-wrap'); if (!_solutionsSim) { _solutionsSim = new SolutionsSim(container); } else { _solutionsSim.fit(); } }); }); }