'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();
}
});
});
}