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> @
393 lines
20 KiB
JavaScript
393 lines
20 KiB
JavaScript
/* 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 { '&':'&','<':'<','>':'>','"':'"',"'":''' }[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);
|