/* 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 = '
' + '' + '
' + fieldHtml('n', 'n, моль', 'химическое количество') + fieldHtml('m', 'm, г', 'масса вещества') + fieldHtml('M', 'M, г/моль', 'молярная масса') + '
' + '
Введите любые два значения — третье вычислится.
' + '
'; function fieldHtml(key, label, hint) { return ''; } var inputs = host.querySelectorAll('input[data-k]'); var out = host.querySelector('[data-out]'); function num(v) { var x = parseFloat(String(v).replace(',', '.')); return isFinite(x) ? x : null; } function recompute(changedKey) { if (lastEdited[0] !== changedKey) { lastEdited.unshift(changedKey); lastEdited = lastEdited.slice(0, 2); } var known = ['n', 'm', 'M'].filter(function (k) { return num(state[k]) !== null; }); // целевое поле — то, что НЕ редактировали последним и пусто/производно var target = ['n', 'm', 'M'].filter(function (k) { return lastEdited.indexOf(k) === -1; })[0]; if (!target) return; var n = num(state.n), m = num(state.m), M = num(state.M); var res = null, formula = ''; if (target === 'n' && m !== null && M) { res = m / M; formula = 'n = m / M = ' + fmt(m) + ' / ' + fmt(M); } else if (target === 'm' && n !== null && M !== null) { res = n * M; formula = 'm = n · M = ' + fmt(n) + ' · ' + fmt(M); } else if (target === 'M' && m !== null && n) { res = m / n; formula = 'M = m / n = ' + fmt(m) + ' / ' + fmt(n); } if (res === null) { out.className = 'mtri-out'; out.textContent = (known.length >= 2) ? 'Проверьте: на ноль делить нельзя.' : 'Введите любые два значения — третье вычислится.'; return; } var unit = target === 'n' ? ' моль' : target === 'm' ? ' г' : ' г/моль'; setField(target, fmt(res)); out.className = 'mtri-out ok'; out.innerHTML = '' + target + ' = ' + fmt(res) + unit + '' + formula + ''; } function setField(key, val) { state[key] = val; for (var i = 0; i < inputs.length; i++) { if (inputs[i].getAttribute('data-k') === key && global.document.activeElement !== inputs[i]) { inputs[i].value = val; } } } for (var i = 0; i < inputs.length; i++) { (function (inp) { inp.addEventListener('input', function () { var k = inp.getAttribute('data-k'); state[k] = inp.value; // если поле очистили — сбросить производное recompute(k); }); })(inputs[i]); } if (state.M) setField('M', fmt(state.M)); return { el: host, get: function () { return { n: num(state.n), m: num(state.m), M: num(state.M) }; }, set: function (k, v) { setField(k, String(v)); recompute(k === 'n' ? 'm' : 'n'); } }; } /* ────────────────────────────────────────────────────────────────────────── equationBalancer(mount, {skeleton}) — проверка расстановки коэффициентов. skeleton: 'H2 + O2 -> H2O'. Рендерит поля коэффициентов перед каждым веществом, кнопку «Проверить»; считает баланс атомов по сторонам и подсвечивает несбалансированные элементы. opts.solution — массив верных коэффициентов (для кнопки «Показать решение»). ────────────────────────────────────────────────────────────────────────── */ function equationBalancer(mount, opts) { var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount; if (!host) return null; opts = opts || {}; var skel = String(opts.skeleton || ''); var sides = skel.split(/->|=|→/); var left = parseSide(sides[0] || ''), right = parseSide(sides[1] || ''); var all = left.concat(right); host.innerHTML = '
' + '
' + renderSpecies(left) + '' + renderSpecies(right) + '
' + '
' + '' + (opts.solution ? '' : '') + '' + '
' + '
' + '
'; 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 += '' + e + '' + l + '' + r + '' + '' + (eq ? '✓' : '≠') + ''; }); out.className = 'ceqb-out ' + (ok ? 'ok' : 'bad'); out.innerHTML = (ok ? '
Уравнение сбалансировано.
' : '
Не сходится — выровняйте выделенные элементы.
') + '' + rows + '
ЭлементСлеваСправа
'; return ok; } var btnCheck = host.querySelector('[data-check]'); var btnSolve = host.querySelector('[data-solve]'); var btnReset = host.querySelector('[data-reset]'); if (btnCheck) btnCheck.addEventListener('click', check); if (btnReset) btnReset.addEventListener('click', function () { for (var i = 0; i < coefs.length; i++) coefs[i].value = '1'; out.className = 'ceqb-out'; out.innerHTML = ''; }); if (btnSolve && opts.solution) btnSolve.addEventListener('click', function () { for (var i = 0; i < coefs.length && i < opts.solution.length; i++) coefs[i].value = String(opts.solution[i]); check(); }); return { el: host, check: check }; } /* 'H2 + O2' -> [{raw:'H2', counts:{H:2}}, {raw:'O2', counts:{O:2}}] */ function parseSide(side) { return String(side).split('+').map(function (t) { return t.trim(); }).filter(Boolean) .map(function (raw) { var r = raw.replace(/^\d+/, '').trim(); // коэффициент в скелете игнорируем return { raw: r, counts: elementCounts(r) }; }); } /* ---- Каркасы-заглушки интерактивных виджетов (реализуются по фазам) ---- */ function notImplemented(name) { return function () { if (global.console && console.warn) { console.warn('[Chem8] ' + name + ' ещё не реализован (Phase 0 заглушка)'); } return null; }; } var Chem8 = { // готово (Phase 0) formula: formula, ionLabel: ionLabel, chemEq: chemEq, toSub: toSub, toSup: toSup, // готово (Phase 1 — движки расчётов) elementCounts: elementCounts, molarMass: molarMass, // school-rounded Ar: Mr(H2O)=18 arOf: arOf, fmt: fmt, moleTriangle: moleTriangle, // §6 — треугольник n–m–M equationBalancer: equationBalancer, // §8 — балансировщик уравнений // заглушки (см. план, разд. B) — наполняются в Phase 2–6 testTube: notImplemented('testTube'), // §18,25 — пробирка: осадок/газ/окраска oxStateCalc: notImplemented('oxStateCalc'), // §42 — калькулятор степени окисления redoxBalancer: notImplemented('redoxBalancer'), // §44 — e-баланс ОВР orbitalDiagram: notImplemented('orbitalDiagram'), // §33 — орбитальная диаграмма solubilityTable: notImplemented('solubilityTable'), // §19,20,48 — таблица растворимости activitySeries: notImplemented('activitySeries'), // §14,20 — ряд активности металлов miniPeriodic: notImplemented('miniPeriodic'), // §1,26,34 — мини-ПСХЭ с подсветкой indicatorScale: notImplemented('indicatorScale'), // §13,14,16,17 — индикатор + шкала pH dissociationAnim: notImplemented('dissociationAnim'),// §47 — анимация растворения classifier: notImplemented('classifier'), // §10,13,16,19,46 — DnD-классификатор geneticMap: notImplemented('geneticMap') // §22 — генетическая карта-граф классов }; global.Chem8 = Chem8; })(typeof window !== 'undefined' ? window : this);