Files
Learn_System/frontend/js/chem8_svg.js
T
Maxim Dolgolyov 6ea140af54 @
feat(chemistry-8): Phase 1 — раздел «Количественные понятия» (§1–9 + ПР1)

Полноценная интерактивная страница chemistry_8_intro.html (9 § + ПР1 + босс):
- §1 карта элементов (Z, название, Ar), §2 калькулятор Mr по формуле
- §3 «порция вещества» n⇒N,m, §4 счётчик частиц N=n·N_A, §5 M+молярный объём
- §6 звёздный виджет: интерактивный треугольник n–m–M
- §7 универсальный калькулятор газа (m–n–V–N), §8 балансировщик уравнений
- §9 пошаговый решатель по уравнению; босс раздела (4 задачи) + ачивка «Счёт в химии»
- прогресс/XP через /api/textbooks/chemistry-8-intro/progress, scrollspy, тема

chem8_svg.js: реализованы движки — molarMass (школьные Ar: Mr(H2O)=18),
elementCounts, moleTriangle, equationBalancer (+ fmt, arOf).

Фикс порядка загрузки: инициализация обёрнута в DOMContentLoaded (defer-скрипты
готовы к этому моменту). Генератор каркасов получил skip-if-exists (--force для перезаписи).

Тесты: chemistry8.test.js (14) + chemistry8-dom.test.js (jsdom-смоук виджетов, 3) — 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-30 14:36:31 +03:00

393 lines
20 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.
/* 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 = ' <span class="ceq-arrow">' + arrowGlyph(s, opts) + condHtml(opts) + '</span> ';
// выделяем стрелку
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 '<span class="ceq">' + html + '</span>';
}
function arrowGlyph(s, opts) {
if (opts.arrow === '<->' || opts.arrow === '<=>' || /<->|<=>|⇌/.test(s)) return '⇌';
return '→'; // →
}
function condHtml(opts) {
if (!opts.cond) return '';
return '<sup class="ceq-cond">' + escapeHtml(opts.cond) + '</sup>';
}
/* одна сторона уравнения: разбор на вещества по '+', значки ↑/↓ */
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 { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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 =
'<div class="mtri">' +
'<svg class="mtri-svg" viewBox="0 0 200 150" aria-hidden="true">' +
'<polygon points="100,14 18,140 182,140" fill="none" stroke="currentColor" stroke-width="2" opacity=".5"/>' +
'<line x1="59" y1="77" x2="141" y2="77" stroke="currentColor" stroke-width="1.5" opacity=".4"/>' +
'<text x="100" y="52" text-anchor="middle" font-size="26" font-weight="800" fill="currentColor">m</text>' +
'<text x="62" y="124" text-anchor="middle" font-size="22" font-weight="800" fill="currentColor">n</text>' +
'<text x="140" y="124" text-anchor="middle" font-size="22" font-weight="800" fill="currentColor">M</text>' +
'</svg>' +
'<div class="mtri-fields">' +
fieldHtml('n', 'n, моль', 'химическое количество') +
fieldHtml('m', 'm, г', 'масса вещества') +
fieldHtml('M', 'M, г/моль', 'молярная масса') +
'</div>' +
'<div class="mtri-out" data-out>Введите любые два значения — третье вычислится.</div>' +
'</div>';
function fieldHtml(key, label, hint) {
return '<label class="mtri-f"><span class="mtri-lab">' + label + '</span>' +
'<input type="text" inputmode="decimal" data-k="' + key + '" placeholder="?" ' +
'title="' + hint + '"></label>';
}
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 = '<b>' + target + ' = ' + fmt(res) + unit + '</b><span class="mtri-form">' + formula + '</span>';
}
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 =
'<div class="ceqb">' +
'<div class="ceqb-row" data-eq>' +
renderSpecies(left) + '<span class="ceqb-arrow">→</span>' + renderSpecies(right) +
'</div>' +
'<div class="ceqb-actions">' +
'<button type="button" class="ceqb-btn primary" data-check>Проверить</button>' +
(opts.solution ? '<button type="button" class="ceqb-btn" data-solve>Показать решение</button>' : '') +
'<button type="button" class="ceqb-btn" data-reset>Сброс</button>' +
'</div>' +
'<div class="ceqb-out" data-out></div>' +
'</div>';
function renderSpecies(list) {
return list.map(function (sp, i) {
var gi = all.indexOf(sp);
return (i ? '<span class="ceqb-plus">+</span>' : '') +
'<span class="ceqb-sp"><input type="number" min="1" step="1" class="ceqb-coef" ' +
'data-i="' + gi + '" value="1"><span class="ceqb-f">' + formula(sp.raw) + '</span></span>';
}).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 += '<tr class="' + (eq ? 'eq' : 'ne') + '"><td>' + e + '</td><td>' + l + '</td><td>' + r + '</td>' +
'<td>' + (eq ? '✓' : '≠') + '</td></tr>';
});
out.className = 'ceqb-out ' + (ok ? 'ok' : 'bad');
out.innerHTML = (ok ? '<div class="ceqb-msg">Уравнение сбалансировано.</div>'
: '<div class="ceqb-msg">Не сходится — выровняйте выделенные элементы.</div>') +
'<table class="ceqb-tab"><thead><tr><th>Элемент</th><th>Слева</th><th>Справа</th><th></th></tr></thead><tbody>' +
rows + '</tbody></table>';
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);