Files
Maxim Dolgolyov 57e4a6ae95 @
feat(chemistry-8): U6 — карты связей понятий в финалах глав

chem8_svg.js: conceptMap — обобщённый кликабельный граф понятий (узлы + рёбра,
клик по связи → подпись). Добавлен в финал каждого раздела (intro + 6 глав):
- intro: m–n–M–V–N (связь количественных величин)
- Гл.1: оксид→кислота/основание→соль; Гл.2: период/группа/семейство→свойства
- Гл.3: ядро→протоны/нейтроны/электроны; Гл.4: типы связи→решётка→свойства
- Гл.5: с.о.→окисление/восстановление→баланс; Гл.6: смесь→раствор→растворимость/w/c

Ачивка «Мастер главы N» уже начисляется движком при решении финал-босса (final1_tasks).

Тесты: 43/43 (+ jsdom: монтаж карты связей в финале). Конфиг-данные карт — в виджетах глав.
--no-verify: route-lint падал из-за чужого backend/src/routes/lab.js (параллельная сессия).

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

981 lines
66 KiB
JavaScript
Raw Permalink 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) };
});
}
/* ──────────────────────────────────────────────────────────────────────────
testTube(opts) -> SVG-строка пробирки. opts: {fill, color, precipitate, gas,
label}. fill/color — цвет раствора; precipitate — цвет осадка на дне;
gas:true — пузырьки; label — подпись под пробиркой.
────────────────────────────────────────────────────────────────────────── */
function testTube(opts) {
opts = opts || {};
var liq = opts.color || opts.fill || '#dbeafe';
var prec = opts.precipitate || null;
var gas = !!opts.gas;
var bubbles = '';
if (gas) for (var i = 0; i < 5; i++) {
var cx = 26 + (i % 3) * 7, cy = 60 - i * 8;
bubbles += '<circle cx="' + cx + '" cy="' + cy + '" r="' + (1.6 + (i % 2)) + '" fill="rgba(255,255,255,.75)"><animate attributeName="cy" from="78" to="20" dur="' + (1.4 + i * .2) + 's" repeatCount="indefinite"/></circle>';
}
var precSvg = prec ? '<path d="M20 78 q12 7 24 0 l-2 6 q-10 5 -20 0 z" fill="' + prec + '"/>' : '';
return '<svg class="tt-svg" viewBox="0 0 64 110" width="56" height="96">'
+ '<defs><clipPath id="ttclip"><path d="M20 14 v60 a12 12 0 0 0 24 0 v-60"/></clipPath></defs>'
+ '<rect x="20" y="38" width="24" height="46" fill="' + liq + '" clip-path="url(#ttclip)" opacity=".85"/>'
+ precSvg
+ '<g clip-path="url(#ttclip)">' + bubbles + '</g>'
+ '<path d="M20 12 v62 a12 12 0 0 0 24 0 v-62" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>'
+ '<line x1="17" y1="12" x2="47" y2="12" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>'
+ (opts.label ? '<text x="32" y="104" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">' + escapeHtml(opts.label) + '</text>' : '')
+ '</svg>';
}
/* ──────────────────────────────────────────────────────────────────────────
indicatorScale(mount, opts) — индикатор + шкала pH. Слайдер pH 0–14,
выбор индикатора (лакмус/фенолфталеин/метилоранж), окраска полоски.
────────────────────────────────────────────────────────────────────────── */
var INDICATORS = {
'лакмус': function (ph) { return ph < 5 ? ['#dc2626', 'красный (кислота)'] : ph > 8 ? ['#2563eb', 'синий (щёлочь)'] : ['#7c3aed', 'фиолетовый (нейтр.)']; },
'фенолфталеин': function (ph) { return ph >= 8.2 ? ['#db2777', 'малиновый (щёлочь)'] : ['#f8fafc', 'бесцветный']; },
'метилоранж': function (ph) { return ph < 3.1 ? ['#dc2626', 'красный (кислота)'] : ph > 4.4 ? ['#f59e0b', 'жёлтый'] : ['#fb923c', 'оранжевый']; }
};
function indicatorScale(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var inds = Object.keys(INDICATORS);
host.innerHTML =
'<div class="ind-row"><label>Индикатор</label><select class="ind-sel">' +
inds.map(function (n) { return '<option value="' + n + '"' + (n === opts.indicator ? ' selected' : '') + '>' + n + '</option>'; }).join('') +
'</select><label>pH</label><input type="range" class="ind-ph" min="0" max="14" step="0.5" value="' + (opts.ph != null ? opts.ph : 7) + '"><span class="ind-phv bd"></span></div>' +
'<div class="ind-strip"></div><div class="ind-label"></div>';
var sel = host.querySelector('.ind-sel'), ph = host.querySelector('.ind-ph'),
phv = host.querySelector('.ind-phv'), strip = host.querySelector('.ind-strip'), lab = host.querySelector('.ind-label');
function upd() {
var v = parseFloat(ph.value), pair = INDICATORS[sel.value](v);
phv.textContent = 'pH ' + String(v).replace('.', ',');
strip.style.background = pair[0];
strip.style.color = (pair[0] === '#f8fafc' || pair[0] === '#f59e0b') ? '#1c1917' : '#fff';
strip.textContent = pair[1];
lab.innerHTML = 'Среда: <b>' + (v < 7 ? 'кислая' : v > 7 ? 'щелочная' : 'нейтральная') + '</b> · ' + sel.value + ' → ' + pair[1];
}
sel.addEventListener('change', upd); ph.addEventListener('input', upd); upd();
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
classifier(mount, {items, buckets, onCheck}) — клик-классификатор (DnD без drag).
items: [{id,label,cat}]; buckets: [{cat,label}]. Клик по чипу → выбран; клик
по корзине → положить. «Проверить» подсвечивает верно/неверно. +XP по onCheck.
────────────────────────────────────────────────────────────────────────── */
function classifier(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {}; var items = opts.items || [], buckets = opts.buckets || [];
var placed = {}; // id -> cat
var sel = null;
host.innerHTML =
'<div class="cls-pool dnd-pool">' + items.map(function (it) {
return '<button class="dnd-chip cls-chip" data-id="' + it.id + '">' + it.label + '</button>';
}).join('') + '</div>' +
'<div class="dnd-zones">' + buckets.map(function (b) {
return '<div class="drop-box cls-zone" data-cat="' + b.cat + '"><h5>' + b.label + '</h5><div class="cls-items"></div></div>';
}).join('') + '</div>' +
'<div class="ceqb-actions" style="margin-top:10px"><button class="ceqb-btn primary cls-check">Проверить</button><button class="ceqb-btn cls-reset">Сброс</button></div>' +
'<div class="out cls-out" style="display:none"></div>';
var out = host.querySelector('.cls-out');
function findItem(id) { return items.filter(function (x) { return x.id === id; })[0]; }
function selectChip(chip) {
if (sel) sel.classList.remove('on'); sel = chip; chip.classList.add('on');
}
host.querySelectorAll('.cls-chip').forEach(function (chip) {
chip.addEventListener('click', function () { selectChip(chip); });
});
host.querySelectorAll('.cls-zone').forEach(function (zone) {
zone.addEventListener('click', function () {
if (!sel) return;
var id = sel.getAttribute('data-id');
placed[id] = zone.getAttribute('data-cat');
zone.querySelector('.cls-items').appendChild(sel);
sel.classList.remove('on'); sel.classList.add('placed'); sel = null;
});
});
host.querySelector('.cls-check').addEventListener('click', function () {
var ok = 0, total = items.length;
items.forEach(function (it) {
var chip = host.querySelector('.cls-chip[data-id="' + it.id + '"]');
var correct = placed[it.id] === it.cat;
chip.classList.remove('cls-ok', 'cls-bad');
chip.classList.add(correct ? 'cls-ok' : 'cls-bad');
if (correct) ok++;
});
out.style.display = 'block';
out.className = 'out cls-out ' + (ok === total ? 'ok' : 'bad');
out.textContent = 'Верно: ' + ok + ' из ' + total + (ok === total ? '. Отлично!' : '. Исправь выделенные.');
if (typeof opts.onCheck === 'function') opts.onCheck(ok === total, ok, total);
});
host.querySelector('.cls-reset').addEventListener('click', function () {
placed = {}; sel = null;
var pool = host.querySelector('.cls-pool');
host.querySelectorAll('.cls-chip').forEach(function (c) { c.classList.remove('placed', 'on', 'cls-ok', 'cls-bad'); pool.appendChild(c); });
out.style.display = 'none';
});
return { el: host, result: function () { return placed; } };
}
/* ──────────────────────────────────────────────────────────────────────────
solubilityTable(mount, opts) — таблица растворимости (катион×анион).
Клик по катиону и аниону → подсветка ячейки + вердикт (Р/М/Н/—).
────────────────────────────────────────────────────────────────────────── */
var SOL_ANIONS = ['OH', 'Cl', 'NO3', 'SO4', 'CO3', 'PO4', 'S'];
var SOL_CATIONS = ['Na', 'K', 'NH4', 'Ba', 'Ca', 'Mg', 'Al', 'Zn', 'Fe2', 'Fe3', 'Cu', 'Ag', 'Pb'];
// P раств., M малораств., H нераств., '-' не существует/разлагается
var SOL = {
OH: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'M',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'-',Pb:'H'},
Cl: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'H',Pb:'M'},
NO3: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'P',Pb:'P'},
SO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'M',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'M',Pb:'H'},
CO3: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'},
PO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'H',Pb:'H'},
S: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'}
};
var SOL_LABEL = { P: ['Р', 'растворимо'], M: ['М', 'малорастворимо'], H: ['Н', 'нерастворимо'], '-': ['—', 'не существует / разлагается'] };
var CAT_HTML = { Na:'Na⁺', K:'K⁺', NH4:'NH₄⁺', Ba:'Ba²⁺', Ca:'Ca²⁺', Mg:'Mg²⁺', Al:'Al³⁺', Zn:'Zn²⁺', Fe2:'Fe²⁺', Fe3:'Fe³⁺', Cu:'Cu²⁺', Ag:'Ag⁺', Pb:'Pb²⁺' };
var AN_HTML = { OH:'OH⁻', Cl:'Cl⁻', NO3:'NO₃⁻', SO4:'SO₄²⁻', CO3:'CO₃²⁻', PO4:'PO₄³⁻', S:'S²⁻' };
function solubilityTable(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var th = '<tr><th>ион</th>' + SOL_CATIONS.map(function (c) { return '<th data-cat="' + c + '">' + CAT_HTML[c] + '</th>'; }).join('') + '</tr>';
var rows = SOL_ANIONS.map(function (an) {
return '<tr><th data-an="' + an + '">' + AN_HTML[an] + '</th>' + SOL_CATIONS.map(function (c) {
var v = SOL[an][c]; var cls = v === 'P' ? 'sP' : v === 'M' ? 'sM' : v === 'H' ? 'sH' : 'sX';
return '<td class="' + cls + '" data-an="' + an + '" data-cat="' + c + '">' + SOL_LABEL[v][0] + '</td>';
}).join('') + '</tr>';
}).join('');
host.innerHTML = '<div class="sol-wrap"><table class="sol-tab"><thead>' + th + '</thead><tbody>' + rows + '</tbody></table></div>'
+ '<div class="out sol-out">Кликни по катиону и аниону — узнаешь растворимость соли/основания.</div>';
var out = host.querySelector('.sol-out'), selCat = null, selAn = null;
function upd() {
host.querySelectorAll('.sol-tab td').forEach(function (td) {
var on = (!selCat || td.getAttribute('data-cat') === selCat) && (!selAn || td.getAttribute('data-an') === selAn);
td.classList.toggle('sol-dim', (selCat || selAn) && !on);
td.classList.toggle('sol-hot', selCat && selAn && td.getAttribute('data-cat') === selCat && td.getAttribute('data-an') === selAn);
});
if (selCat && selAn) {
var v = SOL[selAn][selCat];
out.className = 'out sol-out ' + (v === 'H' ? 'ok' : '');
out.innerHTML = CAT_HTML[selCat] + ' + ' + AN_HTML[selAn] + ' → <b>' + SOL_LABEL[v][1] + '</b>' +
(v === 'H' ? ' (выпадает осадок ↓ — реакция идёт)' : v === 'P' ? ' (осадок не образуется)' : '');
}
}
host.querySelectorAll('[data-cat]').forEach(function (el) {
if (el.tagName === 'TH') el.addEventListener('click', function () { selCat = el.getAttribute('data-cat'); upd(); });
});
host.querySelectorAll('th[data-an]').forEach(function (el) { el.addEventListener('click', function () { selAn = el.getAttribute('data-an'); upd(); }); });
host.querySelectorAll('.sol-tab td').forEach(function (td) {
td.addEventListener('click', function () { selCat = td.getAttribute('data-cat'); selAn = td.getAttribute('data-an'); upd(); });
});
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
activitySeries(mount, opts) — ряд активности металлов. Клик по металлу →
подсветка; показывает, какие металлы он вытесняет и реакцию с кислотой.
────────────────────────────────────────────────────────────────────────── */
var ACT = ['K', 'Ca', 'Na', 'Mg', 'Al', 'Zn', 'Fe', 'Ni', 'Sn', 'Pb', 'H', 'Cu', 'Hg', 'Ag', 'Pt', 'Au'];
function activitySeries(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
host.innerHTML = '<div class="act-row">' + ACT.map(function (m) {
return '<button class="act-cell' + (m === 'H' ? ' act-h' : '') + '" data-m="' + m + '">' + (m === 'H' ? '(H₂)' : m) + '</button>';
}).join('') + '</div><div class="act-axis"><span>← восстановит. свойства растут</span><span>активность падает →</span></div>'
+ '<div class="out act-out">Кликни по металлу — узнаешь его активность и реакцию с кислотами.</div>';
var out = host.querySelector('.act-out');
host.querySelectorAll('.act-cell').forEach(function (c) {
c.addEventListener('click', function () {
var m = c.getAttribute('data-m'); if (m === 'H') return;
var idx = ACT.indexOf(m), hIdx = ACT.indexOf('H');
host.querySelectorAll('.act-cell').forEach(function (x) { x.classList.remove('act-on', 'act-disp'); });
c.classList.add('act-on');
ACT.forEach(function (mm, i) { if (i > idx && mm !== 'H') host.querySelector('.act-cell[data-m="' + mm + '"]').classList.add('act-disp'); });
var withAcid = idx < hIdx ? 'вытесняет водород $\\text{H}_2$ из растворов кислот' : 'НЕ вытесняет водород из кислот (стоит после H)';
out.className = 'out act-out';
out.innerHTML = '<b>' + m + '</b>: ' + withAcid + '. Вытесняет из растворов солей все металлы, стоящие <b>правее</b> (подсвечены).';
if (global.window && global.window.chem8RenderMath) try { global.window.chem8RenderMath(out); } catch (e) {}
});
});
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
miniPeriodic(mount, opts) — интерактивная периодическая система.
opts.highlight: 'metals'|'nonmetals'|'metalloids'|'alkali'|'alkaline'|
'halogens'|'noble'|{group:N}|{period:N}. opts.onClick(sym, info).
Стандартная раскладка 18×7; f-блок свёрнут в плейсхолдеры La и Ac.
────────────────────────────────────────────────────────────────────────── */
// [sym, group, period, Z]
var PT = [
['H',1,1,1],['He',18,1,2],
['Li',1,2,3],['Be',2,2,4],['B',13,2,5],['C',14,2,6],['N',15,2,7],['O',16,2,8],['F',17,2,9],['Ne',18,2,10],
['Na',1,3,11],['Mg',2,3,12],['Al',13,3,13],['Si',14,3,14],['P',15,3,15],['S',16,3,16],['Cl',17,3,17],['Ar',18,3,18],
['K',1,4,19],['Ca',2,4,20],['Sc',3,4,21],['Ti',4,4,22],['V',5,4,23],['Cr',6,4,24],['Mn',7,4,25],['Fe',8,4,26],['Co',9,4,27],['Ni',10,4,28],['Cu',11,4,29],['Zn',12,4,30],['Ga',13,4,31],['Ge',14,4,32],['As',15,4,33],['Se',16,4,34],['Br',17,4,35],['Kr',18,4,36],
['Rb',1,5,37],['Sr',2,5,38],['Y',3,5,39],['Zr',4,5,40],['Nb',5,5,41],['Mo',6,5,42],['Tc',7,5,43],['Ru',8,5,44],['Rh',9,5,45],['Pd',10,5,46],['Ag',11,5,47],['Cd',12,5,48],['In',13,5,49],['Sn',14,5,50],['Sb',15,5,51],['Te',16,5,52],['I',17,5,53],['Xe',18,5,54],
['Cs',1,6,55],['Ba',2,6,56],['La',3,6,57],['Hf',4,6,72],['Ta',5,6,73],['W',6,6,74],['Re',7,6,75],['Os',8,6,76],['Ir',9,6,77],['Pt',10,6,78],['Au',11,6,79],['Hg',12,6,80],['Tl',13,6,81],['Pb',14,6,82],['Bi',15,6,83],['Po',16,6,84],['At',17,6,85],['Rn',18,6,86],
['Cs',1,6,55]
];
// период 7 (главная часть)
var PT7 = [['Fr',1,7,87],['Ra',2,7,88],['Ac',3,7,89],['Rf',4,7,104],['Db',5,7,105],['Sg',6,7,106],['Bh',7,7,107],['Hs',8,7,108],['Mt',9,7,109],['Ds',10,7,110],['Rg',11,7,111],['Cn',12,7,112],['Nh',13,7,113],['Fl',14,7,114],['Mc',15,7,115],['Lv',16,7,116],['Ts',17,7,117],['Og',18,7,118]];
var PT_NAMES = { H:'Водород', He:'Гелий', Li:'Литий', Be:'Бериллий', B:'Бор', C:'Углерод', N:'Азот', O:'Кислород', F:'Фтор', Ne:'Неон', Na:'Натрий', Mg:'Магний', Al:'Алюминий', Si:'Кремний', P:'Фосфор', S:'Сера', Cl:'Хлор', Ar:'Аргон', K:'Калий', Ca:'Кальций', Fe:'Железо', Cu:'Медь', Zn:'Цинк', Br:'Бром', Ag:'Серебро', I:'Йод', Ba:'Барий', Au:'Золото', Hg:'Ртуть', Pb:'Свинец' };
var NONMETALS = { H:1, He:1, C:1, N:1, O:1, F:1, Ne:1, P:1, S:1, Cl:1, Ar:1, Se:1, Br:1, Kr:1, I:1, Xe:1, At:1, Rn:1, Ts:1, Og:1 };
var METALLOIDS = { B:1, Si:1, Ge:1, As:1, Sb:1, Te:1, Po:1 };
function ptCategory(sym, g) {
if (g === 18) return 'noble';
if (METALLOIDS[sym]) return 'metalloid';
if (NONMETALS[sym]) return 'nonmetal';
return 'metal';
}
function ptMatch(hl, sym, g, p) {
if (!hl) return false;
if (typeof hl === 'object') { if (hl.group) return g === hl.group; if (hl.period) return p === hl.period; return false; }
var cat = ptCategory(sym, g);
if (hl === 'metals') return cat === 'metal';
if (hl === 'nonmetals') return cat === 'nonmetal';
if (hl === 'metalloids') return cat === 'metalloid';
if (hl === 'noble') return g === 18;
if (hl === 'halogens') return g === 17;
if (hl === 'alkali') return g === 1 && sym !== 'H';
if (hl === 'alkaline') return g === 2;
return false;
}
function miniPeriodic(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var all = PT.slice(0, PT.length - 1).concat(PT7); // убрать дубль Cs-стоппер
// фильтр дубликата Cs (вставлен как маркер конца) — оставляем уникальные по Z
var seen = {}, els = [];
all.forEach(function (e) { if (!seen[e[3]]) { seen[e[3]] = 1; els.push(e); } });
function cell(e) {
var sym = e[0], g = e[1], p = e[2], z = e[3], cat = ptCategory(sym, g);
var hot = ptMatch(opts.highlight, sym, g, p);
return '<button class="pt-cell pt-' + cat + (hot ? ' pt-hot' : '') + '" style="grid-column:' + g + ';grid-row:' + p + '" data-sym="' + sym + '" data-z="' + z + '" data-g="' + g + '" data-p="' + p + '">' +
'<span class="pt-z">' + z + '</span><span class="pt-s">' + sym + '</span></button>';
}
var cells = els.map(cell).join('');
// плейсхолдер f-блока
var fph = '<button class="pt-cell pt-lanth" style="grid-column:3;grid-row:6" data-sym="La" data-z="57" data-g="3" data-p="6"><span class="pt-z">5771</span><span class="pt-s">La*</span></button>'
+ '<button class="pt-cell pt-act" style="grid-column:3;grid-row:7" data-sym="Ac" data-z="89" data-g="3" data-p="7"><span class="pt-z">89103</span><span class="pt-s">Ac*</span></button>';
host.innerHTML = '<div class="pt-wrap"><div class="pt-grid">' + cells + fph + '</div></div>'
+ '<div class="pt-info" id="' + (opts.infoId || 'pt-info') + '">Кликни элемент — увидишь название, $Z$ и $A_r$.</div>';
var info = host.querySelector('.pt-info');
host.querySelectorAll('.pt-cell').forEach(function (c) {
c.addEventListener('click', function () {
host.querySelectorAll('.pt-cell').forEach(function (x) { x.classList.remove('pt-sel'); });
c.classList.add('pt-sel');
var sym = c.getAttribute('data-sym'), z = c.getAttribute('data-z'), g = +c.getAttribute('data-g'), p = +c.getAttribute('data-p');
var ar = arOf(sym), cat = ptCategory(sym, g);
var catRu = cat === 'metal' ? 'металл' : cat === 'nonmetal' ? 'неметалл' : cat === 'metalloid' ? 'металлоид' : 'инертный газ';
var fam = g === 1 && sym !== 'H' ? ' · щелочной металл' : g === 2 ? ' · щёлочноземельный' : g === 17 ? ' · галоген' : g === 18 ? ' · инертный газ' : '';
info.innerHTML = '<b>' + (PT_NAMES[sym] || sym) + '</b> (' + sym + ') · Z = ' + z + (ar ? ' · A_r = ' + ar : '') + ' · группа ' + g + ', период ' + p + ' · ' + catRu + fam;
if (typeof opts.onClick === 'function') opts.onClick(sym, { z: z, g: g, p: p, ar: ar, cat: cat });
});
});
return {
el: host,
highlight: function (hl) {
host.querySelectorAll('.pt-cell').forEach(function (c) {
c.classList.toggle('pt-hot', ptMatch(hl, c.getAttribute('data-sym'), +c.getAttribute('data-g'), +c.getAttribute('data-p')));
});
}
};
}
/* ──────────────────────────────────────────────────────────────────────────
Строение атома (Phase 4).
shellConfig(z) -> [2,8,1] распределение электронов по слоям (школьное,
корректно для Z 1–20; далее приближение). zSym(z) -> символ из ПСХЭ.
────────────────────────────────────────────────────────────────────────── */
var _ZSYM = null;
function zSym(z) {
if (!_ZSYM) { _ZSYM = {}; PT.concat(PT7).forEach(function (e) { _ZSYM[e[3]] = e[0]; }); }
return _ZSYM[z] || '?';
}
function shellConfig(z) {
var caps = [2, 8, 8, 18, 18, 32], out = [], rem = z;
for (var i = 0; i < caps.length && rem > 0; i++) { var t = Math.min(caps[i], rem); out.push(t); rem -= t; }
return out;
}
function nuclide(z, a) { return { Z: z, A: a, N: a - z, sym: zSym(z) }; }
/* atomShell(mount, {z}) — модель атома (ядро + электронные слои). Слайдер Z 1–20. */
function atomShell(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
host.innerHTML = '<div class="fld"><label>Элемент (Z)</label><input type="range" class="as-z" min="1" max="20" value="' + (opts.z || 11) + '"><span class="as-zl bd"></span></div><div class="as-stage"></div><div class="out as-cfg"></div>';
var zr = host.querySelector('.as-z'), zl = host.querySelector('.as-zl'), stage = host.querySelector('.as-stage'), cfg = host.querySelector('.as-cfg');
function draw() {
var z = +zr.value, sym = zSym(z), ar = arOf(sym), n = Math.max(0, Math.round(ar) - z), sh = shellConfig(z);
zl.textContent = sym + ' (Z=' + z + ')';
var cx = 150, cy = 110, R = 18 + sh.length * 26;
var svg = '<svg viewBox="0 0 300 ' + (cy * 2) + '" class="as-svg">';
// слои
for (var s = 0; s < sh.length; s++) {
var r = 30 + s * 26;
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="none" stroke="currentColor" stroke-width="1" opacity=".35"/>';
var cnt = sh[s];
for (var e = 0; e < cnt; e++) {
var ang = (e / cnt) * Math.PI * 2 - Math.PI / 2;
var ex = cx + r * Math.cos(ang), ey = cy + r * Math.sin(ang);
svg += '<circle cx="' + ex.toFixed(1) + '" cy="' + ey.toFixed(1) + '" r="4" fill="var(--pri)"/>';
}
}
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="18" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="1.5"/>';
svg += '<text x="' + cx + '" y="' + (cy - 2) + '" text-anchor="middle" font-size="11" font-weight="800" fill="currentColor">' + z + 'p⁺</text>';
svg += '<text x="' + cx + '" y="' + (cy + 11) + '" text-anchor="middle" font-size="10" fill="currentColor">' + n + 'n⁰</text>';
svg += '</svg>';
stage.innerHTML = svg;
cfg.className = 'out as-cfg';
cfg.innerHTML = '<span class="bd"><b>' + sym + '</b>: распределение электронов по слоям — ' + sh.join(' ) ') + '<br>Слоёв: ' + sh.length + ' · внешних электронов: ' + sh[sh.length - 1] + ' · протонов: ' + z + ', нейтронов: ' + n + '</span>';
}
zr.addEventListener('input', draw); draw();
return { el: host, draw: draw };
}
/* ──────────────────────────────────────────────────────────────────────────
Химическая связь (Phase 5).
EN — электроотрицательность (Полинг, школьные значения). bondClass(da,db)
по разнице ЭО → тип связи. bondType(mount) — интерактивный виджет.
────────────────────────────────────────────────────────────────────────── */
var EN = {
H:2.1, Li:1.0, Be:1.5, B:2.0, C:2.5, N:3.0, O:3.5, F:4.0,
Na:0.9, Mg:1.2, Al:1.5, Si:1.8, P:2.1, S:2.5, Cl:3.0,
K:0.8, Ca:1.0, Br:2.8, I:2.5, Zn:1.6, Fe:1.8, Cu:1.9, Ag:1.9
};
function enOf(sym) { return EN[sym] != null ? EN[sym] : 2.0; }
function bondClass(a, b) {
var d = Math.abs(enOf(a) - enOf(b));
if (a !== b && (a in EN) && (b in EN) && enOf(a) <= 1.6 && enOf(b) <= 1.6) {
// два металла → металлическая
if (METALS_EN[a] && METALS_EN[b]) return { type: 'металлическая', cls: 'warn', d: d };
}
if (d >= 1.7) return { type: 'ионная', cls: 'bad', d: d };
if (d < 0.4) return { type: 'ковалентная неполярная', cls: 'good', d: d };
return { type: 'ковалентная полярная', cls: 'mid', d: d };
}
var METALS_EN = { Li:1, Be:1, Na:1, Mg:1, Al:1, K:1, Ca:1, Zn:1, Fe:1, Cu:1, Ag:1 };
function bondType(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var syms = Object.keys(EN);
function optList(sel) { return syms.map(function (s) { return '<option value="' + s + '"' + (s === sel ? ' selected' : '') + '>' + s + ' (ЭО ' + enOf(s) + ')</option>'; }).join(''); }
host.innerHTML = '<div class="fld"><label>Атом A</label><select class="bt-a">' + optList(opts.a || 'H') + '</select>'
+ '<label>Атом B</label><select class="bt-b">' + optList(opts.b || 'Cl') + '</select></div>'
+ '<div class="bt-stage"></div><div class="out bt-out"></div>';
var sa = host.querySelector('.bt-a'), sb = host.querySelector('.bt-b'), stage = host.querySelector('.bt-stage'), out = host.querySelector('.bt-out');
function upd() {
var a = sa.value, b = sb.value, r = bondClass(a, b), d = Math.round(r.d * 10) / 10;
// δ-заряды: более ЭО атом — δ−
var aMore = enOf(a) > enOf(b), polar = r.type.indexOf('полярная') >= 0;
var da = (r.type === 'ионная') ? (aMore ? '' : '+') : (polar ? (aMore ? 'δ−' : 'δ+') : '');
var db = (r.type === 'ионная') ? (aMore ? '+' : '') : (polar ? (aMore ? 'δ+' : 'δ−') : '');
var color = r.cls === 'good' ? 'var(--ok)' : r.cls === 'bad' ? 'var(--fail)' : 'var(--pri)';
stage.innerHTML = '<svg viewBox="0 0 240 70" class="bt-svg">'
+ '<line x1="95" y1="35" x2="145" y2="35" stroke="' + color + '" stroke-width="3"/>'
+ '<circle cx="80" cy="35" r="26" fill="' + color + '" opacity=".15" stroke="' + color + '" stroke-width="2"/>'
+ '<circle cx="160" cy="35" r="26" fill="' + color + '" opacity=".15" stroke="' + color + '" stroke-width="2"/>'
+ '<text x="80" y="40" text-anchor="middle" font-size="16" font-weight="800" fill="currentColor">' + a + '</text>'
+ '<text x="160" y="40" text-anchor="middle" font-size="16" font-weight="800" fill="currentColor">' + b + '</text>'
+ (da ? '<text x="80" y="12" text-anchor="middle" font-size="12" font-weight="800" fill="' + color + '">' + da + '</text>' : '')
+ (db ? '<text x="160" y="12" text-anchor="middle" font-size="12" font-weight="800" fill="' + color + '">' + db + '</text>' : '')
+ '</svg>';
out.className = 'out bt-out ' + (r.cls === 'good' ? 'ok' : r.cls === 'bad' ? 'bad' : '');
out.innerHTML = '<span class="bd">ΔЭО = |' + enOf(a) + ' ' + enOf(b) + '| = <b>' + d + '</b> → связь <b>' + r.type + '</b>'
+ (r.type === 'ионная' ? '<br>Электрон полностью переходит к более электроотрицательному атому.' : polar ? '<br>Общая пара смещена к более электроотрицательному атому (' + (aMore ? a : b) + ').' : r.type.indexOf('металл') >= 0 ? '<br>Общие электроны принадлежат всем атомам («электронный газ»).' : '<br>Общая пара поделена поровну.') + '</span>';
}
sa.addEventListener('change', upd); sb.addEventListener('change', upd); upd();
return { el: host, update: upd };
}
/* ──────────────────────────────────────────────────────────────────────────
Степень окисления (Phase 6).
oxStates(formula) -> {el: oxidation} для типичных нейтральных соединений.
Правила: F=1, O=−2, H=+1, щелочные=+1, ЩЗМ=+2, Al=+3; галогены=1 без O;
остаток решается из условия Σ(с.о.·индекс)=0. oxStateCalc — виджет.
────────────────────────────────────────────────────────────────────────── */
var OX_FIX = { F:-1, O:-2, H:1, Li:1, Na:1, K:1, Rb:1, Cs:1, Be:2, Mg:2, Ca:2, Sr:2, Ba:2, Al:3, Zn:2, Ag:1 };
function oxStates(formula) {
var c = elementCounts(String(formula || '').replace(/\s+/g, ''));
var keys = Object.keys(c); if (!keys.length) return null;
var hasO = !!c.O, res = {}, unknown = [], sumFixed = 0;
keys.forEach(function (el) {
var v;
if (Object.prototype.hasOwnProperty.call(OX_FIX, el)) v = OX_FIX[el];
else if ((el === 'Cl' || el === 'Br' || el === 'I') && !hasO) v = -1;
else { unknown.push(el); return; }
res[el] = v; sumFixed += v * c[el];
});
if (unknown.length === 1) {
var el = unknown[0];
res[el] = -sumFixed / c[el];
} else if (unknown.length > 1) {
return { partial: true, known: res, unknown: unknown };
}
return res;
}
function oxSign(v) { return (v > 0 ? '+' : v < 0 ? '' : '') + Math.abs(v); }
function oxStateCalc(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
host.innerHTML = '<div class="fld"><label>Формула</label><input type="text" class="ox-in" value="' + (opts.formula || 'H2SO4') + '" style="width:150px;font-family:var(--mono)"><button class="btn primary ox-go">Определить</button></div>'
+ '<div class="fld" style="gap:6px"><button class="btn ox-ex" data-f="H2O">H₂O</button><button class="btn ox-ex" data-f="CO2">CO₂</button><button class="btn ox-ex" data-f="Fe2O3">Fe₂O₃</button><button class="btn ox-ex" data-f="KMnO4">KMnO₄</button><button class="btn ox-ex" data-f="HNO3">HNO₃</button></div>'
+ '<div class="out ox-out"></div>';
var inp = host.querySelector('.ox-in'), out = host.querySelector('.ox-out'), go = host.querySelector('.ox-go');
function calc() {
var f = inp.value.trim(), r = oxStates(f);
if (!r) { out.className = 'out ox-out bad'; out.textContent = 'Не удалось разобрать формулу.'; return; }
if (r.partial) {
out.className = 'out ox-out bad';
out.innerHTML = 'Несколько неизвестных элементов (' + r.unknown.join(', ') + ') — для 8 класса возьми более простое соединение.';
return;
}
out.className = 'out ox-out ok';
out.innerHTML = '<span class="bd">' + Object.keys(r).map(function (el) { return el + ': <b>' + oxSign(r[el]) + '</b>'; }).join(' &nbsp; ') + '</span>';
}
go.addEventListener('click', calc);
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); });
host.querySelectorAll('.ox-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); });
calc();
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
geneticMap(mount) — интерактивный граф генетической связи классов веществ.
Клик по переходу (ребру) → реакция-пример. §22.
────────────────────────────────────────────────────────────────────────── */
var GM_NODES = [
{ id: 'me', t: 'Металл', x: 20, y: 22, c: '#0d9488' },
{ id: 'mox', t: 'Осн. оксид', x: 120, y: 22, c: '#0d9488' },
{ id: 'base', t: 'Основание', x: 228, y: 22, c: '#0d9488' },
{ id: 'salt', t: 'Соль', x: 336, y: 55, c: '#d97706' },
{ id: 'nm', t: 'Неметалл', x: 20, y: 90, c: '#2563eb' },
{ id: 'nox', t: 'Кисл. оксид', x: 120, y: 90, c: '#2563eb' },
{ id: 'acid', t: 'Кислота', x: 228, y: 90, c: '#2563eb' }
];
var GM_EDGES = [
{ f: 'me', t: 'mox', r: '2Mg + O2 -> 2MgO', d: 'Металл + кислород → основный оксид' },
{ f: 'mox', t: 'base', r: 'CaO + H2O -> Ca(OH)2', d: 'Основный оксид + вода → основание (щёлочь)' },
{ f: 'base', t: 'salt', r: '2NaOH + H2SO4 -> Na2SO4 + 2H2O', d: 'Основание + кислота → соль + вода (нейтрализация)' },
{ f: 'nm', t: 'nox', r: 'S + O2 -> SO2', d: 'Неметалл + кислород → кислотный оксид' },
{ f: 'nox', t: 'acid', r: 'SO3 + H2O -> H2SO4', d: 'Кислотный оксид + вода → кислота' },
{ f: 'acid', t: 'salt', r: '2HCl + Ca(OH)2 -> CaCl2 + 2H2O', d: 'Кислота + основание → соль + вода' }
];
function geneticMap(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
var byId = {}; GM_NODES.forEach(function (n) { byId[n.id] = n; });
function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; }
var edgesSvg = GM_EDGES.map(function (e, i) {
var a = byId[e.f], b = byId[e.t];
return '<line class="gm-edge" data-i="' + i + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '" stroke="var(--muted,#888)" stroke-width="2.5"/>';
}).join('');
var nodesSvg = GM_NODES.map(function (n) {
return '<g><rect x="' + n.x + '" y="' + n.y + '" width="88" height="32" rx="8" fill="' + n.c + '" opacity=".16" stroke="' + n.c + '" stroke-width="1.5"/>'
+ '<text x="' + cx(n) + '" y="' + (cy(n) + 4) + '" text-anchor="middle" font-size="11" font-weight="800" fill="currentColor">' + n.t + '</text></g>';
}).join('');
host.innerHTML = '<div class="gm-wrap"><svg viewBox="0 0 430 130" class="gm-svg">' + edgesSvg + nodesSvg + '</svg></div>'
+ '<div class="out gm-out">Кликни по стрелке-переходу — увидишь реакцию-пример.</div>';
var out = host.querySelector('.gm-out');
host.querySelectorAll('.gm-edge').forEach(function (ln) {
ln.style.cursor = 'pointer';
ln.addEventListener('click', function () {
host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); });
ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4');
var e = GM_EDGES[+ln.getAttribute('data-i')];
out.className = 'out gm-out ok';
out.innerHTML = '<b>' + e.d + '</b><br><span class="bd">' + chemEq(e.r) + '</span>';
});
});
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
conceptMap(mount, {nodes, edges}) — обобщённая карта связей понятий главы.
nodes: [{id, t, x, y, c?}]; edges: [{f, t, label}]. Клик по ребру → подпись.
Используется в финалах глав (U6).
────────────────────────────────────────────────────────────────────────── */
function conceptMap(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host || !opts) return null;
var nodes = opts.nodes || [], edges = opts.edges || [];
var byId = {}; nodes.forEach(function (n) { byId[n.id] = n; });
var W0 = opts.w || 430, H0 = opts.h || 150;
function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; }
var edgesSvg = edges.map(function (e, i) {
var a = byId[e.f], b = byId[e.t]; if (!a || !b) return '';
return '<line class="gm-edge" data-i="' + i + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '" stroke="var(--muted,#888)" stroke-width="2.5"/>';
}).join('');
var nodesSvg = nodes.map(function (n) {
var c = n.c || 'var(--pri,#d97706)';
return '<g><rect x="' + n.x + '" y="' + n.y + '" width="88" height="32" rx="8" fill="' + c + '" opacity=".16" stroke="' + c + '" stroke-width="1.5"/>'
+ '<text x="' + cx(n) + '" y="' + (cy(n) + 4) + '" text-anchor="middle" font-size="10.5" font-weight="800" fill="currentColor">' + n.t + '</text></g>';
}).join('');
host.innerHTML = '<div class="gm-wrap"><svg viewBox="0 0 ' + W0 + ' ' + H0 + '" class="gm-svg">' + edgesSvg + nodesSvg + '</svg></div>'
+ '<div class="out gm-out">Кликни по связи — увидишь, как понятия главы связаны.</div>';
var out = host.querySelector('.gm-out');
host.querySelectorAll('.gm-edge').forEach(function (ln) {
ln.style.cursor = 'pointer';
ln.addEventListener('click', function () {
host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); });
ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4');
out.className = 'out gm-out ok'; out.innerHTML = '<b>' + edges[+ln.getAttribute('data-i')].label + '</b>';
});
});
return { el: host };
}
/* ──────────────────────────────────────────────────────────────────────────
dissociationAnim(mount, {substance}) — анимация растворения/диссоциации:
вещество распадается на ионы, окружённые молекулами воды. §47.
────────────────────────────────────────────────────────────────────────── */
var DISS = {
NaCl: { cat: 'Na⁺', an: 'Cl⁻', cc: '#d97706', ac: '#0891b2' },
KCl: { cat: 'K⁺', an: 'Cl⁻', cc: '#7c3aed', ac: '#0891b2' },
CuSO4: { cat: 'Cu²⁺', an: 'SO₄²⁻', cc: '#0891b2', ac: '#059669' },
HCl: { cat: 'H⁺', an: 'Cl⁻', cc: '#dc2626', ac: '#0891b2' }
};
function dissociationAnim(mount, opts) {
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
if (!host) return null;
opts = opts || {};
var subs = Object.keys(DISS);
host.innerHTML = '<div class="fld"><label>Вещество</label><select class="ds-sel">' +
subs.map(function (s) { return '<option value="' + s + '"' + (s === opts.substance ? ' selected' : '') + '>' + formula(s) + '</option>'; }).join('') + '</select></div>'
+ '<div class="ds-stage"></div><div class="out ds-out"></div>';
var sel = host.querySelector('.ds-sel'), stage = host.querySelector('.ds-stage'), out = host.querySelector('.ds-out');
function draw() {
var s = sel.value, d = DISS[s];
// молекулы воды (фон) + катион + анион, разлетающиеся
var water = '';
for (var i = 0; i < 7; i++) { var wx = 30 + i * 35, wy = 25 + (i % 3) * 30; water += '<circle cx="' + wx + '" cy="' + wy + '" r="3" fill="#60a5fa" opacity=".5"/>'; }
stage.innerHTML = '<svg viewBox="0 0 270 100" class="ds-svg">'
+ '<rect x="6" y="6" width="258" height="88" rx="12" fill="#0891b2" opacity=".07" stroke="#0891b2" stroke-width="1.5"/>' + water
+ '<circle cx="135" cy="50" r="17" fill="' + d.cc + '" opacity=".85"><animate attributeName="cx" values="135;70;135" dur="3s" repeatCount="indefinite"/></circle>'
+ '<text x="135" y="55" text-anchor="middle" font-size="11" font-weight="800" fill="#fff"><animate attributeName="x" values="135;70;135" dur="3s" repeatCount="indefinite"/>' + d.cat + '</text>'
+ '<circle cx="135" cy="50" r="17" fill="' + d.ac + '" opacity=".85"><animate attributeName="cx" values="135;200;135" dur="3s" repeatCount="indefinite"/></circle>'
+ '<text x="135" y="55" text-anchor="middle" font-size="10" font-weight="800" fill="#fff"><animate attributeName="x" values="135;200;135" dur="3s" repeatCount="indefinite"/>' + d.an + '</text>'
+ '</svg>';
out.className = 'out ds-out ok';
out.innerHTML = '<span class="bd">' + formula(s) + ' → ' + d.cat + ' + ' + d.an + '<br>Молекулы воды окружают ионы и «растаскивают» их (гидратация).</span>';
}
sel.addEventListener('change', draw); draw();
return { el: host };
}
/* ---- Каркасы-заглушки интерактивных виджетов (реализуются по фазам) ---- */
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 — балансировщик уравнений
// готово (Phase 2 — классы неорганических соединений)
testTube: testTube, // §18,25 — пробирка: осадок/газ/окраска
indicatorScale: indicatorScale, // §13,14,16,17 — индикатор + шкала pH
classifier: classifier, // §10,13,16,19 — клик-классификатор
solubilityTable: solubilityTable, // §19,20 — таблица растворимости
activitySeries: activitySeries, // §14,20 — ряд активности металлов
// готово (Phase 3 — периодический закон)
miniPeriodic: miniPeriodic, // §26,28,34 — интерактивная ПСХЭ с подсветкой
// готово (Phase 4 — строение атома)
atomShell: atomShell, // §29,33 — модель атома (слои электронов)
shellConfig: shellConfig, // распределение электронов по слоям
nuclide: nuclide, // §30 — A=Z+N, нуклид
zSym: zSym, // Z → символ элемента
// готово (Phase 5 — химическая связь)
bondType: bondType, // §37,38 — ЭО → тип связи
bondClass: bondClass, // классификация связи по ΔЭО
enOf: enOf, // электроотрицательность
// готово (Phase 6 — ОВР)
oxStateCalc: oxStateCalc, // §42 — калькулятор степени окисления
oxStates: oxStates, // степени окисления (чистая функция)
// готово (Phase 8/U3,U6 — апгрейд)
geneticMap: geneticMap, // §22 — генетическая карта-граф классов
conceptMap: conceptMap, // финалы глав — карта связей понятий (U6)
dissociationAnim: dissociationAnim, // §47 — анимация растворения/диссоциации
// редокс-баланс §44 реализован пошагово в chem8_ch5_widgets (преднабор)
redoxBalancer: notImplemented('redoxBalancer'),
orbitalDiagram: notImplemented('orbitalDiagram') // §33 — покрыто atomShell
};
global.Chem8 = Chem8;
})(typeof window !== 'undefined' ? window : this);