/* chem8_svg.js — химические наглядные примитивы для учебника «Химия 8».
*
* Неймспейс: window.Chem8.*
* Молекулярные модели (структурные / шаростержневые / 3D) — НЕ здесь, а через
* biochem-core.js (window.BioChem). Здесь только то, чего там нет: рендер формул и
* уравнений, ионы, степени окисления, интерактивные виджеты (растворимость, ряд
* активности, индикаторы, классификаторы, калькуляторы расчётов и т. п.).
*
* Phase 0: реализованы чистые текстовые примитивы (ionLabel, chemEq, formula).
* Остальные хелперы — каркасы-заглушки, наполняются по фазам (см. PLAN_CHEMISTRY_8.md, разд. B).
*
* Правила (CLAUDE.md / план):
* - без эмоджи, только inline SVG .ic;
* - в KaTeX-шаблонах двойной backslash (\\to, \\downarrow, \\rightleftharpoons);
* - drag/слайдеры: window-listeners + state ВЫШЕ redraw(), без setPointerCapture.
*/
(function (global) {
'use strict';
var SUB = { '0':'₀','1':'₁','2':'₂','3':'₃','4':'₄',
'5':'₅','6':'₆','7':'₇','8':'₈','9':'₉' };
var SUP = { '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴',
'5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹',
'+':'⁺','-':'⁻' };
function toSub(digits) {
return String(digits).replace(/[0-9]/g, function (d) { return SUB[d]; });
}
function toSup(s) {
return String(s).replace(/[0-9+\-]/g, function (c) { return SUP[c] || c; });
}
/* formula('CaCO3') -> 'CaCO₃' : числовые индексы атомов в подстрочные.
Не трогает множители-коэффициенты в начале (их рендерит chemEq). */
function formula(src) {
if (src == null) return '';
return String(src).replace(/([A-Za-z\)\]])(\d+)/g, function (_, a, n) {
return a + toSub(n);
});
}
/* ionLabel('SO4', -2) -> 'SO₄²⁻' ; ionLabel('Ca', 2) -> 'Ca²⁺' ; ionLabel('Na', 1) -> 'Na⁺' */
function ionLabel(form, charge) {
var body = formula(form);
var c = Number(charge) || 0;
if (c === 0) return body;
var mag = Math.abs(c);
var sign = c > 0 ? '+' : '-';
var num = mag === 1 ? '' : String(mag);
return body + toSup(num + sign);
}
/* chemEq('2Na + 2H2O -> 2NaOH + H2^', {arrow:'->'}) -> HTML-строка с индексами,
стрелками (= → ⇌), значками газа (↑) и осадка (↓), условием над стрелкой.
Токены: '->'/'=' необратимая, '<->'/'<=>' обратимая, '^' газ, 'v' осадок.
opts.cond — подпись над стрелкой (например 't', 'кат.', 'эл. ток'). */
function chemEq(src, opts) {
opts = opts || {};
var s = String(src == null ? '' : src).trim();
var arrowHtml = ' ' + arrowGlyph(s, opts) + condHtml(opts) + ' ';
// выделяем стрелку
var parts = s.split(/<->|<=>|->|⇌|=(?![^(]*\))|→/);
var left = parts[0] || '';
var right = parts.length > 1 ? parts.slice(1).join(' ') : '';
var html = renderSide(left);
if (right) html += arrowHtml + renderSide(right);
return '' + html + '';
}
function arrowGlyph(s, opts) {
if (opts.arrow === '<->' || opts.arrow === '<=>' || /<->|<=>|⇌/.test(s)) return '⇌';
return '→'; // →
}
function condHtml(opts) {
if (!opts.cond) return '';
return '' + escapeHtml(opts.cond) + '';
}
/* одна сторона уравнения: разбор на вещества по '+', значки ↑/↓ */
function renderSide(side) {
return side.split('+').map(function (term) {
var t = term.trim();
if (!t) return '';
var gas = false, prec = false;
t = t.replace(/\^|↑/g, function () { gas = true; return ''; })
.replace(/(^|[A-Za-z0-9\)])v(\b|$)|↓/g, function (m) {
prec = true; return m.replace(/v|↓/, '');
});
// коэффициент в начале
var coef = '';
t = t.replace(/^(\d+)/, function (_, n) { coef = n; return ''; });
var out = (coef ? coef : '') + formula(t.trim());
if (gas) out += '↑';
if (prec) out += '↓';
return out;
}).filter(Boolean).join(' + ');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c];
});
}
/* ── Относительные атомные массы Ar (школьно-округлённые, как в учебнике РБ).
Намеренно НЕ берём точные массы biochem-core: для 8 класса Mr(H₂O)=18,
Mr(CaCO₃)=100 и т. п. — иначе расходимся с ответами учебника. ── */
var AR = {
H:1, He:4, Li:7, Be:9, B:11, C:12, N:14, O:16, F:19, Ne:20,
Na:23, Mg:24, Al:27, Si:28, P:31, S:32, Cl:35.5, Ar:40, K:39, Ca:40,
Sc:45, Ti:48, V:51, Cr:52, Mn:55, Fe:56, Co:59, Ni:59, Cu:64, Zn:65,
Ga:70, Ge:73, As:75, Se:79, Br:80, Kr:84, Rb:85, Sr:88, Ag:108, Cd:112,
Sn:119, Sb:122, I:127, Xe:131, Ba:137, Pt:195, Au:197, Hg:201, Pb:207, Bi:209
};
function arOf(sym) {
if (Object.prototype.hasOwnProperty.call(AR, sym)) return AR[sym];
// запасной путь — точная масса из biochem-core, если элемента нет в школьной таблице
if (global.BIO && global.BIO.ELEMENTS && global.BIO.ELEMENTS[sym]) {
return Math.round(global.BIO.ELEMENTS[sym].mass);
}
return 0;
}
/* elementCounts('Ca(OH)2') -> {Ca:1, O:2, H:2} (скобки и индексы) */
function elementCounts(str) {
var out = {}, stack = [out];
var re = /([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)/g, m;
while ((m = re.exec(str)) !== null) {
if (m[1]) {
var n = m[2] ? parseInt(m[2], 10) : 1;
var top = stack[stack.length - 1];
top[m[1]] = (top[m[1]] || 0) + n;
} else if (m[3]) {
stack.push({});
} else if (m[4] !== undefined) {
var grp = stack.pop(), mult = m[5] ? parseInt(m[5], 10) : 1, t2 = stack[stack.length - 1];
for (var k in grp) t2[k] = (t2[k] || 0) + grp[k] * mult;
}
}
return out;
}
/* molarMass('CaCO3') -> 100 (г/моль), на школьных Ar. NaN при неизвестном элементе. */
function molarMass(str) {
var c = elementCounts(String(str || '').replace(/\s+/g, ''));
var keys = Object.keys(c);
if (!keys.length) return NaN;
var m = 0;
for (var i = 0; i < keys.length; i++) {
var a = arOf(keys[i]);
if (!a) return NaN;
m += a * c[keys[i]];
}
return Math.round(m * 1000) / 1000;
}
/* Округление до значащих для вывода (избегаем 18.000000002). */
function fmt(x, d) {
if (!isFinite(x)) return '—';
var p = Math.pow(10, d == null ? 3 : d);
return String(Math.round(x * p) / p);
}
/* ──────────────────────────────────────────────────────────────────────────
moleTriangle(mount, opts) — интерактивный калькулятор-треугольник n–m–M.
Пользователь вводит любые два из {n, m, M} — третье считается (n=m/M,
m=n·M, M=m/n). opts.substance — предзаполнить M по формуле (через molarMass).
Возвращает {el, get, set}. Без setPointerCapture, чистый DOM.
────────────────────────────────────────────────────────────────────────── */
function moleTriangle(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var state = { n: '', m: '', M: opts.substance ? molarMass(opts.substance) : '' };
var lastEdited = []; // последние два редактированных поля → третье вычисляем
host.innerHTML =
'
';
function renderSpecies(list) {
return list.map(function (sp, i) {
var gi = all.indexOf(sp);
return (i ? '+' : '') +
'' + formula(sp.raw) + '';
}).join('');
}
var out = host.querySelector('[data-out]');
var coefs = host.querySelectorAll('.ceqb-coef');
function getCoef(i) { var v = parseInt((coefs[i] && coefs[i].value) || '1', 10); return v > 0 ? v : 1; }
function tally(list, fromIdx) {
var acc = {};
list.forEach(function (sp, j) {
var c = getCoef(all.indexOf(sp));
for (var e in sp.counts) acc[e] = (acc[e] || 0) + sp.counts[e] * c;
});
return acc;
}
function check() {
var L = tally(left), R = tally(right);
var elems = {}; Object.keys(L).forEach(function (e) { elems[e] = 1; }); Object.keys(R).forEach(function (e) { elems[e] = 1; });
var rows = '', ok = true;
Object.keys(elems).sort().forEach(function (e) {
var l = L[e] || 0, r = R[e] || 0, eq = l === r;
if (!eq) ok = false;
rows += '