Files
Learn_System/frontend/js/labs/solutions.js
T
Maxim Dolgolyov ea2526dc73 feat(labs): 4 школьные хим. симы + визуальная прокачка лаборатории
4 НОВЫЕ СИМЫ (школьная программа 8-11 классов):

Органика (organic.js, 1545 строк):
- Конструктор молекул: drag атомов C/H/O/N/Cl/S, валентности, click-pair bonds
- Авто-определение класса: алкан/алкен/алкин/спирт/альдегид/кислота/эфир/амин/аромат
- IUPAC-имена для C1-C10
- Гомологические ряды: 7 рядов с slider количества углеродов, M, T_кип, T_пл
- 6 качественных реакций: Br₂ вода, KMnO₄, Ag₂O/NH₃ (серебряное зеркало), Cu(OH)₂, FeCl₃, I₂

Периодическая таблица (periodic.js, 118 элементов):
- Стандартный вид 18×9 + лантаноиды/актиноиды
- Карточка элемента: Z, M, конфигурация, степени окисления, ЭО, ρ, T_пл/T_кип
- Боровская модель электронных оболочек (анимированная)
- Подсветка: 11 типов / s/p/d/f-блоки / без подсветки
- Графики свойств по периоду/группе (ЭО, M, плотность, T_пл/T_кип)
- Поиск по символу/имени/Z/массе

Качественный анализ (qualanalysis.js, 24 иона):
- 15 катионов: Na/K/NH₄/Mg/Ca/Ba/Al/Fe²⁺/Fe³⁺/Cu/Ag/Pb/Zn/H/OH
- 10 анионов: Cl/Br/I/SO₄/SO₃/CO₃/NO₃/PO₄/S²/CH₃COO
- 9 реактивов + пламя
- 2 режима: «определи ион» и «неизвестное вещество» с логом наблюдений
- Анимация капли, осадка с цветом, газовых пузырей, пламени

Растворы (solutions.js, 4 режима):
- Калькулятор: m_в, m_р-ра, ρ, T → ω, ν, C_М, C_Н с понятной логикой пересчёта
- Разбавление с before/after визуализацией
- Смешивание двух растворов с правилом рычага
- Кривые растворимости 8 веществ + задача перекристаллизации
- 15 пресетов веществ (NaCl, NaOH, H₂SO₄, CuSO₄·5H₂O, глюкоза, сахароза, ...)

ВИЗУАЛЬНАЯ ПРОКАЧКА (_chem_visuals.js, helper file):

12 функций школьной лабораторной графики:
- drawErlenmeyer / drawBeaker / drawBurette / drawTube — proper SVG-paths со шкалой
- drawSpiritLamp — стеклянный резервуар + фитиль + анимированное пламя
- animateGasBubbles / animatePrecipitateFall — анимация продуктов
- drawProductLabel — fade-in/out стрелка ↑/↓ с подписью
- drawEduTooltip — bubble с пояснением реакции
- drawDeskBackground / drawVesselShadow — лабораторный фон
- drawPHStrip — pH-индикаторная полоса с маркером

Прокачено 6 chem-сим: chemsandbox, flask, titration, electrolysis, ionexchange, redox
Каждая получила: фон парты, тени под колбами, анимированные стрелки продуктов,
educational tooltips из поля 'why' реакции. Спиртовка с пламенем в flask.
pH-полоса в titration.

Каталог теперь: 39 симуляций (было 35 + 4 новых).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 13:08:35 +03:00

1140 lines
49 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';
/* ═══════════════════════════════════════════════════════════════════════
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<sub>в</sub> — масса растворённого', '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<sub>р-ра</sub> — масса раствора', '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<sub>воды</sub> — масса воды', 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<sub>М</sub> — молярная', cM.toFixed(4), 'моль/л', '#F15BB5', 'C_M = \\frac{\\nu}{V}'));
r.appendChild(resLine('C<sub>Н</sub> — нормальность', 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<sub>воды</sub> — объём воды', 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<sub>в</sub> = const', m_solute.toFixed(2), 'г', '#4CC9F0'));
r.appendChild(valLine('m<sub>р-ра новая</sub>', 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();
}
});
});
}