ea2526dc73
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>
1140 lines
49 KiB
JavaScript
1140 lines
49 KiB
JavaScript
'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();
|
||
}
|
||
});
|
||
});
|
||
}
|