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>
@
This commit is contained in:
Maxim Dolgolyov
2026-05-30 14:36:31 +03:00
parent a587cf3b1e
commit 39515af6bf
5 changed files with 1082 additions and 76 deletions
+12 -4
View File
@@ -287,11 +287,19 @@ const _TB_SLUG = '${ch.slug}';
`;
}
let count = 0;
// --force перезапишет уже существующие файлы; по умолчанию — пропускаем
// готовые (наполненные в фазах) страницы, чтобы не затереть контент.
const FORCE = process.argv.includes('--force');
let count = 0, skipped = 0;
for (const ch of CHAPTERS) {
const html = pageHtml(ch);
fs.writeFileSync(path.join(OUT, ch.file), html, 'utf8');
const target = path.join(OUT, ch.file);
if (!FORCE && fs.existsSync(target)) {
skipped++;
console.log('skip ', ch.file, '(уже существует — наполнен в фазе)');
continue;
}
fs.writeFileSync(target, pageHtml(ch), 'utf8');
count++;
console.log('written', ch.file, '(' + ch.items.filter(i => i.t).length + ' §)');
}
console.log('done:', count, 'chapter skeletons');
console.log('done:', count, 'written,', skipped, 'skipped');
+63
View File
@@ -0,0 +1,63 @@
'use strict';
/*
* jsdom-смоук виджетов chem8_svg.js: реальная отрисовка в DOM, ввод, проверка.
* Ловит рантайм-ошибки DOM-манипуляций, которые не видны в чистых юнит-тестах.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const SRC = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'chem8_svg.js'), 'utf8');
function mkDom() {
const dom = new JSDOM('<!DOCTYPE html><body><div id="m"></div><div id="b"></div></body>');
// выполняем модуль так, что его `window` === jsdom-окно
new Function('window', SRC)(dom.window);
return { dom, C: dom.window.Chem8, doc: dom.window.document };
}
function fire(el, type) {
el.dispatchEvent(new el.ownerDocument.defaultView.Event(type, { bubbles: true }));
}
test('moleTriangle монтируется и считает m = n·M', () => {
const { C, doc } = mkDom();
const api = C.moleTriangle(doc.getElementById('m'), {});
assert.ok(api && api.el, 'виджет смонтирован');
const inputs = doc.querySelectorAll('#m input[data-k]');
assert.equal(inputs.length, 3, '3 поля');
const byKey = {};
inputs.forEach(i => { byKey[i.getAttribute('data-k')] = i; });
// вводим n=2, затем M=18 → ожидаем m=36
byKey.n.value = '2'; fire(byKey.n, 'input');
byKey.M.value = '18'; fire(byKey.M, 'input');
const out = doc.querySelector('#m [data-out]');
assert.ok(/36/.test(out.textContent), 'm = 36 вычислено: ' + out.textContent);
});
test('equationBalancer: неверные коэффициенты → дисбаланс, верные → баланс', () => {
const { C, doc } = mkDom();
const api = C.equationBalancer(doc.getElementById('b'),
{ skeleton: 'H2 + O2 -> H2O', solution: [2, 1, 2] });
assert.ok(api && api.check, 'виджет смонтирован');
// по умолчанию все коэффициенты = 1 → не сбалансировано
assert.equal(api.check(), false, '1·H2 + 1·O2 -> 1·H2O не сбалансировано');
const out = doc.querySelector('#b [data-out]');
assert.ok(out.className.includes('bad'), 'подсветка дисбаланса');
// применяем решение через кнопку
doc.querySelector('#b [data-solve]').dispatchEvent(
new doc.defaultView.Event('click', { bubbles: true }));
assert.ok(out.className.includes('ok'), 'после решения — сбалансировано: ' + out.className);
});
test('equationBalancer считает атомы для сложной реакции', () => {
const { C, doc } = mkDom();
const api = C.equationBalancer(doc.getElementById('b'),
{ skeleton: 'Al + HCl -> AlCl3 + H2', solution: [2, 6, 2, 3] });
const coefs = doc.querySelectorAll('#b .ceqb-coef');
[2, 6, 2, 3].forEach((v, i) => { coefs[i].value = String(v); });
assert.equal(api.check(), true, '2Al + 6HCl -> 2AlCl3 + 3H2 сбалансировано');
});
+41 -1
View File
@@ -49,13 +49,34 @@ test('Chem8.chemEq — обратимая реакция и осадок', () =>
assert.ok(prec.includes('AgCl↓'), 'значок осадка');
});
test('Chem8.molarMass — школьные Ar (Mr из учебника)', () => {
assert.equal(C.molarMass('H2O'), 18);
assert.equal(C.molarMass('CaCO3'), 100);
assert.equal(C.molarMass('H2SO4'), 98);
assert.equal(C.molarMass('Al2(SO4)3'), 342);
assert.equal(C.molarMass('NaOH'), 40);
assert.ok(Number.isNaN(C.molarMass('Xx9')), 'неизвестный элемент → NaN');
});
test('Chem8.elementCounts — скобки и индексы', () => {
assert.deepEqual(C.elementCounts('Ca(OH)2'), { Ca: 1, O: 2, H: 2 });
assert.deepEqual(C.elementCounts('Al2(SO4)3'), { Al: 2, S: 3, O: 12 });
assert.deepEqual(C.elementCounts('CO2'), { C: 1, O: 2 });
});
test('Chem8 — заглушки возвращают null и не падают', () => {
for (const fn of ['testTube', 'moleTriangle', 'solubilityTable', 'oxStateCalc', 'geneticMap']) {
for (const fn of ['testTube', 'solubilityTable', 'oxStateCalc', 'geneticMap']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
assert.equal(C[fn]({}), null, fn + ' заглушка возвращает null');
}
});
test('Chem8 — движки расчётов экспортированы как функции', () => {
for (const fn of ['moleTriangle', 'equationBalancer']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
}
});
// --- каркас страниц ---
const CHILDREN = [
{ slug: 'chemistry-8-intro', file: 'chemistry_8_intro.html', paras: 9 },
@@ -90,6 +111,25 @@ test('каждая глава существует и задаёт свой _TB_
}
});
test('Phase 1 — раздел intro наполнен (9 § + ПР1 + босс)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_intro.html'), 'utf8');
for (let i = 1; i <= 9; i++) assert.ok(html.includes('id="p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="pr1"'), 'ПР1');
assert.ok(html.includes('id="boss"'), 'босс раздела');
assert.ok(html.includes('id="mt-mount"'), 'треугольник n–m–M');
assert.ok(html.includes('id="bal-mount"'), 'балансировщик');
assert.ok(html.includes("READ_IDS = ['p1','p2','p3','p4','p5','p6','p7','p8','p9']"), '9 читаемых § для прогресса');
assert.ok(!html.includes('Раздел в разработке'), 'баннер-заглушка убран');
});
test('Phase 1 — ответы босса согласованы с molarMass', () => {
// значения в боссе intro должны совпадать с движком
assert.equal(C.molarMass('H2SO4'), 98); // задача 1
assert.equal(C.molarMass('NaOH'), 40); // задача 2 (M в условии)
assert.ok(Math.abs(3 * 22.4 - 67.2) < 1e-9); // задача 3: V=n·Vm
assert.ok(Math.abs(2 * 6.02 - 12.04) < 1e-9); // задача 4: N=n·N_A
});
test('миграция 041 — родитель chemistry-8 + 7 детей, нет эмоджи', () => {
const sql = fs.readFileSync(
path.join(ROOT, 'backend', 'src', 'db', 'migrations', '041_chemistry8_hub.sql'), 'utf8');
+256 -3
View File
@@ -102,6 +102,254 @@
});
}
/* ── Относительные атомные массы 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 () {
@@ -119,10 +367,15 @@
chemEq: chemEq,
toSub: toSub,
toSup: toSup,
// заглушки (см. план, разд. B) — наполняются в Phase 1–6
// готово (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 — пробирка: осадок/газ/окраска
moleTriangle: notImplemented('moleTriangle'), // §6 — треугольник n–m–M
equationBalancer: notImplemented('equationBalancer'),// §8 — балансировщик уравнений
oxStateCalc: notImplemented('oxStateCalc'), // §42 — калькулятор степени окисления
redoxBalancer: notImplemented('redoxBalancer'), // §44 — e-баланс ОВР
orbitalDiagram: notImplemented('orbitalDiagram'), // §33 — орбитальная диаграмма
+710 -68
View File
@@ -7,61 +7,200 @@
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Вводный раздел · «Количественные понятия в химии»</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false})"></script>
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<script src="/js/biochem-core.js" defer></script>
<script src="/js/chem8_svg.js" defer></script>
<style>
:root{
--bg:#fffbeb; --card:#fff; --text:#1c1917; --muted:#78716c; --border:#e7e5e4;
--bg:#fffbeb; --card:#fff; --card-soft:#fef9ec; --text:#1c1917; --muted:#78716c; --border:#f0e6cf;
--pri:#d97706; --pri-d:#b45309; --pri-l:#fbbf24; --pri-soft:#fef3c7;
--sh:0 4px 16px rgba(0,0,0,.06); --sh-h:0 12px 32px rgba(0,0,0,.12);
--ok:#15803d; --ok-bg:#dcfce7; --bad:#b91c1c; --bad-bg:#fee2e2; --warn:#b45309; --warn-bg:#fef3c7;
--sh:0 1px 3px rgba(120,80,10,.07); --sh2:0 8px 28px rgba(120,80,10,.13);
--mono:'JetBrains Mono',ui-monospace,monospace;
}
html.dark{ --bg:#1c1410; --card:#271c14; --text:#fef3c7; --muted:#a8a29e; --border:#3a3026; --pri-soft:rgba(0,0,0,.2); }
*{margin:0;padding:0;box-sizing:border-box}
html.dark{ --bg:#1c1410; --card:#271c14; --card-soft:#2e2118; --text:#fef3c7; --muted:#c9ab82; --border:#4a3520;
--pri-soft:rgba(217,119,6,.18); --ok-bg:rgba(21,128,61,.2); --bad-bg:rgba(185,28,28,.2); --warn-bg:rgba(180,83,9,.2); }
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{min-height:100vh}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;transition:background .25s,color .25s}
a{color:inherit}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.hdr{position:relative;background:linear-gradient(110deg,#b45309 0%,#d97706 55%,#fbbf24 100%);color:#fff;padding:34px 24px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
.hdr::before{content:'ВВОДНЫЙ РАЗДЕЛ';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(4rem,13vw,10rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.13);line-height:1;pointer-events:none;user-select:none;z-index:0}
.hdr-inner{position:relative;z-index:1;max-width:1000px;margin:0 auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
/* HEADER */
.hdr{position:relative;background:linear-gradient(110deg,#92400e 0%,#d97706 55%,#fbbf24 100%);color:#fff;padding:34px 24px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
.hdr::before{content:'ВВОДНЫЙ РАЗДЕЛ';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(3rem,9vw,7rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.12);line-height:1;pointer-events:none;user-select:none;z-index:0;white-space:nowrap}
.hdr-inner{position:relative;z-index:1;max-width:1180px;margin:0 auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.16);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
.hdr-back:hover{background:rgba(255,255,255,.26)}
.hdr-kicker{font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.14em;opacity:.85}
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.55rem;font-weight:900;letter-spacing:-.01em;line-height:1.25;margin-top:3px}
.hdr-side{margin-left:auto}
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.5rem;font-weight:900;letter-spacing:-.01em;line-height:1.25;margin-top:3px}
.hdr-side{margin-left:auto;display:flex;gap:8px}
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit}
.hdr-btn:hover{background:rgba(255,255,255,.26)}
main{max-width:1000px;margin:0 auto;padding:28px 24px 60px}
/* HERO */
.hero{max-width:1180px;margin:18px auto 0;padding:0 24px}
.hero-card{background:linear-gradient(135deg,var(--pri-soft),rgba(251,191,36,.1));border:1px solid var(--border);border-radius:16px;padding:16px 20px;display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.hero-ic{width:46px;height:46px;border-radius:12px;background:linear-gradient(135deg,#d97706,#fbbf24);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:'Outfit';font-weight:900;font-size:1.2rem}
.hero-t{flex:1;min-width:180px}
.hero-lab{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
.hero-bar{height:8px;background:rgba(217,119,6,.16);border-radius:5px;overflow:hidden;margin-top:6px}
.hero-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--pri-l));border-radius:5px;width:0;transition:width .5s}
.hero-xp{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,#f59e0b,var(--pri));color:#fff;border-radius:99px;font-size:.8rem;font-weight:800;font-family:'Unbounded'}
.wip{display:flex;gap:14px;align-items:flex-start;background:linear-gradient(135deg,var(--pri-soft),rgba(0,0,0,.02));border:1.5px dashed var(--pri);border-radius:16px;padding:18px 20px;margin-bottom:26px}
.wip-ic{width:42px;height:42px;border-radius:11px;background:var(--pri);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.wip-ic svg{width:22px;height:22px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.wip h2{font-family:'Outfit',sans-serif;font-size:1.05rem;color:var(--pri-d);margin-bottom:4px}
html.dark .wip h2{color:var(--pri-l)}
.wip p{font-size:.9rem;color:var(--muted);line-height:1.55}
/* LAYOUT */
.wrap{max-width:1180px;margin:0 auto;padding:24px;display:grid;grid-template-columns:240px 1fr;gap:26px;align-items:start}
@media(max-width:900px){.wrap{grid-template-columns:1fr;padding:16px}}
.side{position:sticky;top:14px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:10px;box-shadow:var(--sh);max-height:calc(100vh - 28px);overflow:auto}
@media(max-width:900px){.side{position:static;max-height:none}}
.side-h{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:6px 10px}
.side a{display:flex;gap:9px;align-items:center;padding:8px 10px;border-radius:9px;text-decoration:none;font-size:.86rem;color:var(--text);transition:background .14s}
.side a:hover{background:var(--pri-soft)}
.side a.active{background:var(--pri-soft);color:var(--pri-d);font-weight:700}
.side a.note{color:var(--muted);font-size:.8rem}
.side-num{font-weight:800;color:var(--pri);min-width:30px;font-size:.82rem}
.side a.done .side-num::after{content:'\2713';margin-left:3px;color:var(--ok)}
.ol-title{font-family:'Outfit',sans-serif;font-size:1.15rem;font-weight:800;margin:6px 0 14px;display:flex;align-items:center;gap:9px}
.ol-title svg{width:20px;height:20px;stroke:var(--pri);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.ol-list{list-style:none;background:var(--card);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--sh)}
.ol-para,.ol-note{display:flex;gap:12px;align-items:baseline;padding:12px 18px;border-bottom:1px solid var(--border)}
.ol-list li:last-child{border-bottom:0}
.ol-num{flex-shrink:0;min-width:46px;font-weight:800;color:var(--pri);font-size:.92rem}
.ol-name{font-size:.94rem;color:var(--text)}
.ol-note{background:var(--pri-soft);align-items:center;gap:10px}
.ol-note-ic{display:inline-flex;color:var(--pri-d)}
html.dark .ol-note-ic{color:var(--pri-l)}
.ol-note-ic svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.ol-note span:last-child{font-size:.88rem;font-weight:600;color:var(--pri-d)}
html.dark .ol-note span:last-child{color:var(--pri-l)}
/* SECTIONS */
.col{min-width:0;display:flex;flex-direction:column;gap:26px}
.sec{scroll-margin-top:14px}
.ph{border-radius:16px;padding:20px 22px;color:#fff;position:relative;overflow:hidden;background:linear-gradient(135deg,var(--pri-d),var(--pri) 60%,var(--pri-l))}
.ph::after{content:'';position:absolute;right:-30px;top:-30px;width:150px;height:150px;border-radius:50%;background:rgba(255,255,255,.1)}
.ph-num{font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
.ph h2{font-family:'Outfit';font-size:1.22rem;font-weight:800;margin:4px 0 8px;line-height:1.25;position:relative;z-index:1}
.ph-key{display:inline-block;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.25);border-radius:10px;padding:6px 14px;font-size:.95rem;font-weight:700;position:relative;z-index:1}
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px 18px;box-shadow:var(--sh)}
.card+.card{margin-top:14px}
.card h3{font-family:'Outfit';font-size:1rem;font-weight:800;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.card h3 .tg{font-size:.64rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:2px 8px;border-radius:99px;background:var(--pri-soft);color:var(--pri-d)}
.card p{font-size:.94rem;margin-bottom:8px}
.card p:last-child{margin-bottom:0}
.card ul{margin:6px 0 8px 20px;font-size:.92rem}
.card li{margin-bottom:4px}
.def{background:var(--pri-soft);border-left:4px solid var(--pri);border-radius:0 10px 10px 0;padding:11px 14px;font-size:.94rem;margin:8px 0}
.def b{color:var(--pri-d)}
html.dark .def b{color:var(--pri-l)}
.exa{background:var(--card-soft);border:1px dashed var(--border);border-radius:10px;padding:12px 14px;font-size:.92rem;margin-top:8px}
.exa .step{margin:5px 0;padding-left:14px;position:relative}
.exa .step::before{content:'';position:absolute;left:0;top:9px;width:6px;height:6px;border-radius:50%;background:var(--pri)}
.note-safe{display:flex;gap:9px;background:var(--warn-bg);border:1px solid var(--pri-l);border-radius:10px;padding:10px 13px;font-size:.88rem;margin-top:8px}
.note-safe svg{stroke:var(--pri-d);margin-top:2px}
/* WIDGET shell */
.wgt{background:var(--card);border:1.5px solid var(--pri-soft);border-radius:14px;padding:16px 18px;box-shadow:var(--sh);margin-top:14px}
.wgt-h{font-family:'Outfit';font-size:.96rem;font-weight:800;color:var(--pri-d);margin-bottom:10px;display:flex;align-items:center;gap:8px}
html.dark .wgt-h{color:var(--pri-l)}
.wgt-h svg{stroke:var(--pri)}
.fld{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
.fld label{font-size:.86rem;font-weight:600;color:var(--muted)}
input[type=text],input[type=number],select{font-family:inherit;font-size:.95rem;padding:8px 11px;border:1.5px solid var(--border);border-radius:9px;background:var(--card);color:var(--text);transition:border-color .14s}
input:focus,select:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.btn{font-family:inherit;font-weight:700;font-size:.9rem;padding:8px 16px;border-radius:9px;border:1.5px solid var(--border);background:var(--card);color:var(--text);cursor:pointer;transition:.14s}
.btn:hover{border-color:var(--pri);background:var(--pri-soft)}
.btn.primary{background:linear-gradient(135deg,var(--pri),var(--pri-l));color:#fff;border-color:transparent}
.btn.primary:hover{filter:brightness(1.07)}
.out{margin-top:10px;padding:11px 14px;border-radius:10px;font-size:.94rem;background:var(--card-soft);border:1px solid var(--border)}
.out.ok{background:var(--ok-bg);border-color:#86efac;color:var(--ok)}
.out.bad{background:var(--bad-bg);border-color:#fca5a5;color:var(--bad)}
.bd{font-family:var(--mono);font-size:.9rem;line-height:1.7}
/* mole triangle */
.mtri{display:grid;grid-template-columns:170px 1fr;gap:16px;align-items:center}
@media(max-width:560px){.mtri{grid-template-columns:1fr}}
.mtri-svg{width:170px;height:128px;color:var(--pri)}
.mtri-fields{display:flex;flex-direction:column;gap:9px}
.mtri-f{display:flex;flex-direction:column;gap:3px}
.mtri-lab{font-size:.78rem;font-weight:700;color:var(--muted)}
.mtri-f input{width:100%}
.mtri-out{grid-column:1/-1;padding:10px 13px;border-radius:10px;background:var(--card-soft);border:1px solid var(--border);font-size:.92rem}
.mtri-out.ok{background:var(--ok-bg);border-color:#86efac;color:var(--ok)}
.mtri-out b{display:block;font-size:1.02rem}
.mtri-form{display:block;font-family:var(--mono);font-size:.84rem;opacity:.85;margin-top:3px}
/* equation balancer */
.ceqb-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:1.05rem;font-weight:600;margin-bottom:12px}
.ceqb-sp{display:inline-flex;align-items:center;gap:3px}
.ceqb-coef{width:46px;text-align:center;padding:6px 4px;font-weight:800}
.ceqb-f{font-weight:700}
.ceqb-plus,.ceqb-arrow{color:var(--muted);font-weight:800;padding:0 2px}
.ceqb-arrow{color:var(--pri);font-size:1.2rem}
.ceqb-actions{display:flex;gap:8px;flex-wrap:wrap}
.ceqb-out{margin-top:10px}
.ceqb-msg{font-weight:700;margin-bottom:6px}
.ceqb-out.ok .ceqb-msg{color:var(--ok)}
.ceqb-out.bad .ceqb-msg{color:var(--bad)}
.ceqb-tab{border-collapse:collapse;font-size:.86rem;font-family:var(--mono)}
.ceqb-tab th,.ceqb-tab td{border:1px solid var(--border);padding:4px 12px;text-align:center}
.ceqb-tab tr.ne td{background:var(--bad-bg);color:var(--bad)}
.ceqb-tab tr.eq td{background:var(--ok-bg);color:var(--ok)}
/* element lookup */
.el-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(54px,1fr));gap:6px;margin-top:8px}
.el-cell{aspect-ratio:1;border:1px solid var(--border);border-radius:8px;background:var(--card);display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;transition:.12s;padding:2px}
.el-cell:hover,.el-cell.on{background:var(--pri-soft);border-color:var(--pri);transform:translateY(-2px)}
.el-cell .z{font-size:.6rem;color:var(--muted)}
.el-cell .s{font-size:1.05rem;font-weight:800;color:var(--pri-d)}
html.dark .el-cell .s{color:var(--pri-l)}
.el-cell .a{font-size:.56rem;color:var(--muted)}
.el-info{margin-top:10px;padding:12px 14px;border-radius:10px;background:var(--card-soft);border:1px solid var(--border);font-size:.94rem;min-height:48px}
/* trainer / proverka */
.tr{margin-top:12px;background:var(--card-soft);border:1px solid var(--border);border-radius:12px;padding:13px 15px}
.tr-q{font-size:.94rem;font-weight:600;margin-bottom:9px}
.tr-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.tr-fb{margin-top:8px;font-size:.88rem;font-weight:600;display:none}
.tr-fb.show{display:block}
.tr-fb.ok{color:var(--ok)}
.tr-fb.bad{color:var(--bad)}
.opt{padding:7px 13px;border:1.5px solid var(--border);border-radius:9px;background:var(--card);cursor:pointer;font-size:.9rem;font-weight:600;transition:.12s}
.opt:hover{border-color:var(--pri)}
.opt.ok{background:var(--ok-bg);border-color:#86efac;color:var(--ok)}
.opt.bad{background:var(--bad-bg);border-color:#fca5a5;color:var(--bad)}
/* mark read */
.mark{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.mark .btn.primary{padding:9px 18px}
.mark .done-lab{font-size:.86rem;font-weight:700;color:var(--ok);display:none;align-items:center;gap:6px}
.mark.done .done-lab{display:inline-flex}
.mark.done .btn{display:none}
/* boss */
.boss{background:var(--card);border:2px solid var(--pri-soft);border-radius:16px;padding:18px;box-shadow:var(--sh2)}
.boss-h{font-family:'Outfit';font-size:1.1rem;font-weight:800;color:var(--pri-d);display:flex;align-items:center;gap:9px;margin-bottom:6px}
html.dark .boss-h{color:var(--pri-l)}
.boss-sub{font-size:.88rem;color:var(--muted);margin-bottom:12px}
.bossbar{display:flex;gap:12px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.bossbar .lab{font-weight:700;font-size:.9rem}
.bossbar .bar{flex:1;min-width:140px;height:8px;background:var(--pri-soft);border-radius:5px;overflow:hidden}
.bossbar .fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--pri-l));width:0;transition:width .5s}
.bcard{border:1.5px solid var(--border);border-radius:12px;padding:14px;margin-bottom:11px;transition:.3s}
.bcard.solved{border-color:#86efac;box-shadow:0 0 0 3px var(--ok-bg)}
.bcard .q{font-size:.95rem;margin-bottom:10px}
.bcard .q b{color:var(--pri-d)}
html.dark .bcard .q b{color:var(--pri-l)}
.bcard .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.bcard .fb{margin-top:8px;font-size:.88rem;font-weight:600;display:none}
.bcard .fb.show{display:block}
.bcard .fb.ok{color:var(--ok)}.bcard .fb.bad{color:var(--bad)}
.hintbox{margin-top:8px;padding:9px 12px;background:var(--warn-bg);border-left:3px solid var(--pri);border-radius:7px;font-size:.86rem;display:none}
.hintbox.show{display:block}
.final-cta{margin-top:14px;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#fef3c7,#fde68a);border:1.5px solid var(--pri-l);display:none;gap:13px;align-items:center;flex-wrap:wrap}
.final-cta.show{display:flex}
html.dark .final-cta{background:linear-gradient(135deg,rgba(217,119,6,.2),rgba(180,83,9,.18))}
.final-cta b{color:#92400e;font-family:'Outfit'}
html.dark .final-cta b{color:#fde68a}
.final-cta a{margin-left:auto;padding:9px 16px;border-radius:9px;background:var(--pri);color:#fff;text-decoration:none;font-weight:700;font-size:.88rem}
.foot{text-align:center;padding:26px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:10px}
.popup{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(120px);background:linear-gradient(135deg,var(--pri),var(--pri-l));color:#fff;padding:12px 22px;border-radius:12px;font-weight:700;box-shadow:var(--sh2);z-index:50;transition:transform .35s;font-size:.92rem}
.popup.show{transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>
@@ -73,7 +212,7 @@ html.dark .ol-note span:last-child{color:var(--pri-l)}
К разделам
</a>
<div>
<div class="hdr-kicker">Вводный раздел &middot; § 19</div>
<div class="hdr-kicker">Вводный раздел &middot; § 19 &middot; ПР 1</div>
<h1>Количественные понятия в химии</h1>
</div>
<div class="hdr-side">
@@ -85,42 +224,263 @@ html.dark .ol-note span:last-child{color:var(--pri-l)}
</div>
</header>
<main>
<section class="wip">
<div class="wip-ic">
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6.3 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6z"/></svg>
<div class="hero">
<div class="hero-card">
<div class="hero-ic">n</div>
<div class="hero-t">
<div class="hero-lab">Прогресс раздела</div>
<div id="prog-text" style="font-weight:700">0 из 9 параграфов · 0%</div>
<div class="hero-bar"><div class="hero-fill" id="prog-fill"></div></div>
</div>
<div>
<h2>Раздел в разработке</h2>
<p>Интерактивное наглядное наполнение этого раздела (теория, модели, симуляторы, тренажёры и боссы) добавляется поэтапно. Ниже — план параграфов раздела согласно учебнику.</p>
</div>
</section>
<div class="ol-title">
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
Содержание раздела
<div class="hero-xp" id="xp-badge">0 XP</div>
</div>
<ul class="ol-list">
<li class="ol-para"><span class="ol-num">§ 1</span><span class="ol-name">Атомы. Химические элементы. Относительная атомная масса</span></li>
<li class="ol-para"><span class="ol-num">§ 2</span><span class="ol-name">Молекулы. Простые и сложные вещества. Химические формулы. Относительная молекулярная масса</span></li>
<li class="ol-para"><span class="ol-num">§ 3</span><span class="ol-name">Химическое количество вещества</span></li>
<li class="ol-para"><span class="ol-num">§ 4</span><span class="ol-name">Моль — единица химического количества вещества. Постоянная Авогадро</span></li>
<li class="ol-para"><span class="ol-num">§ 5</span><span class="ol-name">Молярная масса. Молярный объём газов</span></li>
<li class="ol-para"><span class="ol-num">§ 6</span><span class="ol-name">Вычисление химического количества вещества по его массе и массы вещества по его химическому количеству</span></li>
<li class="ol-para"><span class="ol-num">§ 7</span><span class="ol-name">Вычисление химического количества газа по его объёму и объёма газа по его химическому количеству</span></li>
<li class="ol-note"><span class="ol-note-ic"><svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span><span>Практическая работа 1. Химическое количество вещества</span></li>
<li class="ol-para"><span class="ol-num">§ 8</span><span class="ol-name">Химические реакции</span></li>
<li class="ol-para"><span class="ol-num">§ 9</span><span class="ol-name">Количественные расчёты по уравнениям химических реакций</span></li>
</ul>
</main>
</div>
<footer class="foot">
Интерактивный учебник «Химия — 8 класс» &middot; Вводный раздел &middot; LearnSpace
</footer>
<div class="wrap">
<nav class="side" id="side">
<div class="side-h">Содержание</div>
</nav>
<main class="col" id="col">
<section class="sec" id="p1">
<div class="ph"><div class="ph-num">§ 1</div><h2>Атомы. Химические элементы. Относительная атомная масса</h2><span class="ph-key">$A_r(\text{O}) = 16$</span></div>
<div class="card">
<h3><span class="tg">теория</span> Атом и химический элемент</h3>
<p><b>Атом</b> — мельчайшая химически неделимая частица вещества. <b>Химический элемент</b> — вид атомов с одинаковым зарядом ядра. Каждый элемент имеет символ (например, <b>H</b>, <b>O</b>, <b>Fe</b>) и порядковый номер $Z$ в периодической системе.</p>
<div class="def"><b>Относительная атомная масса</b> $A_r$ показывает, во сколько раз масса атома больше $\tfrac{1}{12}$ массы атома углерода-12. Величина безразмерная: $A_r(\text{H})=1$, $A_r(\text{O})=16$, $A_r(\text{Fe})=56$.</div>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v10M7 12h10"/></svg> Карта элементов: клик → $Z$, название, $A_r$</div>
<div class="el-grid" id="el-grid"></div>
<div class="el-info" id="el-info">Выберите элемент, чтобы увидеть его характеристики.</div>
</div>
<div class="tr" data-tr="t1">
<div class="tr-q">Во сколько раз атом серы ($A_r=32$) тяжелее атома кислорода ($A_r=16$)?</div>
<div class="tr-row" data-opts><button class="opt">в 2 раза</button><button class="opt">в 16 раз</button><button class="opt">в 48 раз</button></div>
<div class="tr-fb"></div>
</div>
<div class="mark" data-mark="p1"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p2">
<div class="ph"><div class="ph-num">§ 2</div><h2>Молекулы. Простые и сложные вещества. Химические формулы. $M_r$</h2><span class="ph-key">$M_r=\sum A_r$</span></div>
<div class="card">
<h3><span class="tg">теория</span> Вещества и формулы</h3>
<p><b>Простое вещество</b> образовано атомами одного элемента ($\text{O}_2$, $\text{Fe}$), <b>сложное</b> — разных ($\text{H}_2\text{O}$, $\text{CaCO}_3$). <b>Химическая формула</b> показывает качественный и количественный состав: индекс — число атомов элемента.</p>
<div class="def"><b>Относительная молекулярная масса</b> $M_r$ равна сумме относительных атомных масс всех атомов в формуле. Например, $M_r(\text{H}_2\text{O}) = 2\cdot1 + 16 = 18$.</div>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> Калькулятор $M_r$ по формуле</div>
<div class="fld">
<label>Формула</label>
<input type="text" id="mr-in" value="CaCO3" style="width:160px;font-family:var(--mono)">
<button class="btn primary" id="mr-go">Вычислить</button>
</div>
<div class="fld" style="gap:6px">
<button class="btn mr-ex" data-f="H2O">H&#8322;O</button>
<button class="btn mr-ex" data-f="H2SO4">H&#8322;SO&#8324;</button>
<button class="btn mr-ex" data-f="Ca(OH)2">Ca(OH)&#8322;</button>
<button class="btn mr-ex" data-f="Al2(SO4)3">Al&#8322;(SO&#8324;)&#8323;</button>
</div>
<div class="out" id="mr-out">Введите формулу и нажмите «Вычислить».</div>
</div>
<div class="mark" data-mark="p2"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p3">
<div class="ph"><div class="ph-num">§ 3</div><h2>Химическое количество вещества</h2><span class="ph-key">$n$, моль</span></div>
<div class="card">
<h3><span class="tg">теория</span> Порция вещества</h3>
<p>Считать атомы и молекулы поштучно невозможно — их слишком много. Поэтому ввели специальную «порцию» — <b>химическое количество вещества</b> $n$, единица — <b>моль</b>. Одна и та же порция ($1$ моль) любого вещества содержит одинаковое число частиц.</p>
<div class="def">Химическое количество $n$ связывает массу $m$, число частиц $N$ и объём газа $V$. Это «мост» между миром атомов и граммами на весах.</div>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2"/><circle cx="12" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="12" cy="18" r="2"/></svg> Порция вещества: $n \Rightarrow N$ и $m$</div>
<div class="fld">
<label>Вещество</label>
<select id="port-sub"><option value="H2O">вода H&#8322;O (M=18)</option><option value="O2">кислород O&#8322; (M=32)</option><option value="CO2">углекислый газ CO&#8322; (M=44)</option><option value="NaCl">соль NaCl (M=58,5)</option></select>
<label>n, моль</label>
<input type="range" id="port-n" min="0.1" max="5" step="0.1" value="1" style="vertical-align:middle">
<span class="bd" id="port-nv">1,0</span>
</div>
<div class="out" id="port-out"></div>
</div>
<div class="mark" data-mark="p3"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p4">
<div class="ph"><div class="ph-num">§ 4</div><h2>Моль — единица химического количества. Постоянная Авогадро</h2><span class="ph-key">$N = n\cdot N_A$</span></div>
<div class="card">
<h3><span class="tg">теория</span> Постоянная Авогадро</h3>
<div class="def"><b>1 моль</b> — это химическое количество вещества, содержащее столько же частиц, сколько атомов в $12$ г углерода-$12$, а именно $N_A = 6{,}02\cdot10^{23}$ частиц/моль — <b>постоянная Авогадро</b>.</div>
<p>Число частиц: $N = n\cdot N_A$. Отсюда $n = \dfrac{N}{N_A}$.</p>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2v20M2 12h20"/></svg> Счётчик частиц $N = n\cdot N_A$</div>
<div class="fld"><label>n, моль</label><input type="range" id="av-n" min="0.25" max="10" step="0.25" value="2"><span class="bd" id="av-nv">2,0</span></div>
<div class="out" id="av-out"></div>
</div>
<div class="tr" data-tr="t4">
<div class="tr-q">Сколько молекул содержится в $0{,}5$ моль воды? ($N_A=6{,}02\cdot10^{23}$)</div>
<div class="tr-row" data-opts><button class="opt" data-ok>$3{,}01\cdot10^{23}$</button><button class="opt">$6{,}02\cdot10^{23}$</button><button class="opt">$12{,}04\cdot10^{23}$</button></div>
<div class="tr-fb"></div>
</div>
<div class="mark" data-mark="p4"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p5">
<div class="ph"><div class="ph-num">§ 5</div><h2>Молярная масса. Молярный объём газов</h2><span class="ph-key">$V_m=22{,}4$ л/моль</span></div>
<div class="card">
<h3><span class="tg">теория</span> M и Vm</h3>
<div class="def"><b>Молярная масса</b> $M$ — масса $1$ моль вещества (г/моль). Численно $M$ равна $M_r$: $M(\text{H}_2\text{O})=18$ г/моль.</div>
<div class="def"><b>Молярный объём</b> $V_m$ — объём $1$ моль газа. При нормальных условиях (н.у.) $V_m = 22{,}4$ л/моль для любого газа (закон Авогадро).</div>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg> M по формуле и объём 1 моль газа</div>
<div class="fld"><label>Формула газа</label><input type="text" id="m5-in" value="CO2" style="width:140px;font-family:var(--mono)"><button class="btn primary" id="m5-go">Найти M</button></div>
<div class="out" id="m5-out">M(CO&#8322;) и объём при н.у. появятся здесь.</div>
</div>
<div class="mark" data-mark="p5"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p6">
<div class="ph"><div class="ph-num">§ 6 &middot; звёздный виджет</div><h2>Вычисление $n$ по массе и массы по $n$</h2><span class="ph-key">$n = \dfrac{m}{M}$</span></div>
<div class="card">
<h3><span class="tg">правило</span> Треугольник nmM</h3>
<p>Три величины связаны формулой $m = n\cdot M$. Закрой искомую — получишь формулу: $n=\dfrac{m}{M}$, $m=n\cdot M$, $M=\dfrac{m}{n}$.</p>
<div class="exa">
<div class="step">Дано: $m=36$ г воды, $M=18$ г/моль.</div>
<div class="step">$n = \dfrac{m}{M} = \dfrac{36}{18} = 2$ моль.</div>
</div>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M12 3 2 21h20z"/></svg> Интерактивный треугольник n–m–M</div>
<div class="fld"><label>Подставить M вещества</label><select id="mt-sub"><option value="">— вручную —</option><option value="H2O">H&#8322;O · 18</option><option value="CO2">CO&#8322; · 44</option><option value="NaOH">NaOH · 40</option><option value="CaCO3">CaCO&#8323; · 100</option><option value="H2SO4">H&#8322;SO&#8324; · 98</option></select></div>
<div id="mt-mount"></div>
</div>
<div class="mark" data-mark="p6"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p7">
<div class="ph"><div class="ph-num">§ 7</div><h2>Вычисление $n$ газа по объёму и объёма по $n$</h2><span class="ph-key">$n = \dfrac{V}{V_m}$</span></div>
<div class="card">
<h3><span class="tg">правило</span> Связка m – n – V – N</h3>
<p>Для газа при н.у.: $n=\dfrac{V}{V_m}$, $V=n\cdot V_m$ ($V_m=22{,}4$ л/моль). Вместе с $n=\dfrac{m}{M}$ и $N=n\cdot N_A$ это единая система: зная одно — найдёшь всё.</p>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12h18M12 3v18"/></svg> Универсальный калькулятор газа</div>
<div class="fld"><label>Газ</label><select id="g7-sub"><option value="O2">O&#8322; · M=32</option><option value="CO2">CO&#8322; · M=44</option><option value="H2">H&#8322; · M=2</option><option value="N2">N&#8322; · M=28</option></select></div>
<div class="fld"><label>Известно</label>
<select id="g7-key"><option value="n">n, моль</option><option value="m">m, г</option><option value="V">V, л (н.у.)</option><option value="N">N, частиц</option></select>
<input type="text" id="g7-val" value="1" style="width:120px;font-family:var(--mono)">
<button class="btn primary" id="g7-go">Рассчитать</button>
</div>
<div class="out" id="g7-out"></div>
</div>
<div class="mark" data-mark="p7"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="pr1">
<div class="ph" style="background:linear-gradient(135deg,#7c2d12,#ea580c 60%,#fb923c)"><div class="ph-num">Практическая работа 1</div><h2>Химическое количество вещества</h2><span class="ph-key">$n=\dfrac{m}{M}$</span></div>
<div class="card">
<h3><span class="tg">практика</span> Порядок работы</h3>
<ul>
<li>Взвесь на весах образцы веществ (например, $\text{NaCl}$, $\text{CuSO}_4$).</li>
<li>Запиши массу $m$ и определи молярную массу $M$ по формуле.</li>
<li>Вычисли химическое количество $n=\dfrac{m}{M}$ и число частиц $N=n\cdot N_A$.</li>
<li>Оформи вывод: какому числу частиц соответствует взятая масса.</li>
</ul>
<div class="note-safe"><svg class="ic" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg> Работай аккуратно с реактивами и весами; не пробуй вещества на вкус.</div>
</div>
<div class="mark" data-mark="pr1"><button class="btn primary">Отметить выполненной (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Выполнено</span></div>
</section>
<section class="sec" id="p8">
<div class="ph"><div class="ph-num">§ 8 &middot; звёздный виджет</div><h2>Химические реакции</h2><span class="ph-key">закон сохранения массы</span></div>
<div class="card">
<h3><span class="tg">теория</span> Уравнение реакции</h3>
<p>В химической реакции одни вещества превращаются в другие, но <b>атомы не исчезают и не появляются</b> (закон сохранения массы М. В. Ломоносова, А. Лавуазье). Поэтому уравнение реакции <b>уравнивают коэффициентами</b> — число атомов каждого элемента слева и справа равно.</p>
<p>Типы реакций: <b>соединения</b> ($A+B\to AB$), <b>разложения</b> ($AB\to A+B$), <b>замещения</b> ($A+BC\to AC+B$), <b>обмена</b> ($AB+CD\to AD+CB$).</p>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12h18M14 6l6 6-6 6"/></svg> Балансировщик: расставь коэффициенты</div>
<div class="fld" style="gap:6px"><label>Реакция</label>
<select id="bal-pick">
<option value="H2 + O2 -> H2O|2,1,2">H&#8322; + O&#8322; &rarr; H&#8322;O</option>
<option value="Fe + O2 -> Fe2O3|4,3,2">Fe + O&#8322; &rarr; Fe&#8322;O&#8323;</option>
<option value="Al + HCl -> AlCl3 + H2|2,6,2,3">Al + HCl &rarr; AlCl&#8323; + H&#8322;</option>
<option value="CH4 + O2 -> CO2 + H2O|1,2,1,2">CH&#8324; + O&#8322; &rarr; CO&#8322; + H&#8322;O</option>
</select>
</div>
<div id="bal-mount"></div>
</div>
<div class="mark" data-mark="p8"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="p9">
<div class="ph"><div class="ph-num">§ 9 &middot; звёздный виджет</div><h2>Количественные расчёты по уравнениям реакций</h2><span class="ph-key">по мольным отношениям</span></div>
<div class="card">
<h3><span class="tg">правило</span> Алгоритм расчёта</h3>
<ul>
<li>Записать и уравнять уравнение реакции.</li>
<li>Найти $n$ известного вещества: $n=\dfrac{m}{M}$ (или $\dfrac{V}{V_m}$).</li>
<li>По коэффициентам найти $n$ искомого (мольное отношение).</li>
<li>Перейти к массе/объёму: $m=n\cdot M$ ($V=n\cdot V_m$).</li>
</ul>
</div>
<div class="wgt">
<div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg> Пошаговый решатель по уравнению</div>
<div class="fld"><label>Задача</label><select id="st-pick"></select></div>
<div class="out" id="st-out"></div>
<div class="fld"><button class="btn" id="st-step">Следующий шаг ▸</button><button class="btn" id="st-all">Показать всё решение</button></div>
</div>
<div class="mark" data-mark="p9"><button class="btn primary">Отметить изученным (+5 XP)</button><span class="done-lab"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Изучено</span></div>
</section>
<section class="sec" id="boss">
<div class="boss">
<div class="boss-h"><svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><path d="M7 4h10v6a5 5 0 0 1-10 0V4z"/><path d="M9 20h6M12 15v5"/></svg> Босс раздела: количественные понятия</div>
<div class="boss-sub">4 задачи на всё, что изучено. За каждую — +10 XP. Победишь всех — ачивка «Счёт в химии» и +30 XP.</div>
<div class="bossbar"><span class="lab" id="boss-lab">Решено: 0 / 4</span><div class="bar"><div class="fill" id="boss-fill"></div></div></div>
<div id="boss-cont"></div>
<div class="final-cta" id="boss-cta">
<svg class="ic" viewBox="0 0 24 24" style="width:26px;height:26px;stroke:var(--pri-d)"><path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6"/></svg>
<b>Вводный раздел пройден! Ачивка «Счёт в химии» получена.</b>
<a href="/textbook/chemistry-8">К разделам →</a>
</div>
</div>
</section>
</main>
</div>
<div class="popup" id="popup"></div>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Вводный раздел · LearnSpace</footer>
<script>
'use strict';
/* Инициализация — после DOMContentLoaded: к этому моменту отложенные (defer)
chem8_svg.js / biochem-core.js / api.js / xp.js уже выполнены и доступны. */
document.addEventListener('DOMContentLoaded', function(){
const _TB_SLUG = 'chemistry-8-intro';
const C = window.Chem8 || {};
const Nav = [
{id:'p1',n:'§ 1',t:'Атомы. Относительная атомная масса'},
{id:'p2',n:'§ 2',t:'Формулы. Молекулярная масса'},
{id:'p3',n:'§ 3',t:'Химическое количество'},
{id:'p4',n:'§ 4',t:'Моль. Постоянная Авогадро'},
{id:'p5',n:'§ 5',t:'Молярная масса и объём'},
{id:'p6',n:'§ 6',t:'Треугольник n–m–M'},
{id:'p7',n:'§ 7',t:'Расчёты для газов'},
{id:'pr1',n:'ПР',t:'Практическая работа 1',note:true},
{id:'p8',n:'§ 8',t:'Химические реакции'},
{id:'p9',n:'§ 9',t:'Расчёты по уравнениям'},
{id:'boss',n:'★',t:'Босс раздела',note:true}
];
const READ_IDS = ['p1','p2','p3','p4','p5','p6','p7','p8','p9'];
/* theme */
(function(){
var saved = localStorage.getItem('chemistry8_theme') || localStorage.getItem('theme') || 'light';
if (saved === 'dark') document.documentElement.classList.add('dark');
@@ -128,12 +488,294 @@ const _TB_SLUG = 'chemistry-8-intro';
if (lab) lab.textContent = saved === 'dark' ? 'Светлая' : 'Тёмная';
document.getElementById('theme-btn').addEventListener('click', function(){
document.documentElement.classList.toggle('dark');
var dark = document.documentElement.classList.contains('dark');
localStorage.setItem('chemistry8_theme', dark ? 'dark' : 'light');
localStorage.setItem('theme', dark ? 'dark' : 'light');
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
var d = document.documentElement.classList.contains('dark');
localStorage.setItem('chemistry8_theme', d?'dark':'light');
localStorage.setItem('theme', d?'dark':'light');
if (lab) lab.textContent = d?'Светлая':'Тёмная';
});
})();
/* sidebar */
(function(){
var side = document.getElementById('side');
Nav.forEach(function(it){
var a = document.createElement('a');
a.href = '#'+it.id; a.id = 'nav-'+it.id;
if (it.note) a.className = 'note';
a.innerHTML = '<span class="side-num">'+it.n+'</span><span>'+it.t+'</span>';
side.appendChild(a);
});
})();
/* progress / XP */
var PROG = {};
function popup(msg){ var p=document.getElementById('popup'); p.textContent=msg; p.classList.add('show'); setTimeout(function(){p.classList.remove('show');},2200); }
function loadLocal(){ try{ var s=localStorage.getItem('chemistry8_intro_read'); if(s) PROG=JSON.parse(s)||{}; }catch(e){} }
function saveLocal(){ try{ localStorage.setItem('chemistry8_intro_read', JSON.stringify(PROG)); }catch(e){} }
function getXp(){ return parseInt(localStorage.getItem('chemistry8_xp')||'0',10)||0; }
function addXp(n,src){
localStorage.setItem('chemistry8_xp', String(getXp()+n));
try{ if(window.LS&&LS.xp&&LS.xp.add) LS.xp.add(n,'chem8-intro-'+(src||'x')); }catch(e){}
refreshXp();
}
function refreshXp(){ var b=document.getElementById('xp-badge'); if(b) b.textContent=getXp()+' XP'; }
var _marked=new Set(), _pending=null, _timer=null;
function _flush(){ var body=_pending; _pending=null; if(!body) return; var tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
fetch('/api/textbooks/'+_TB_SLUG+'/progress',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(body),keepalive:true}).catch(function(){}); }
function _queue(p){ _pending=Object.assign(_pending||{},p); if(_timer)clearTimeout(_timer); _timer=setTimeout(_flush,600); }
function markServerRead(id){ if(_marked.has(id))return; _marked.add(id); _queue({mark_read:id}); }
window.addEventListener('beforeunload',_flush);
function refreshProgress(){
var read = READ_IDS.filter(function(id){return PROG[id];}).length;
var pct = Math.round(read*100/READ_IDS.length);
var t=document.getElementById('prog-text'); if(t) t.textContent=read+' из '+READ_IDS.length+' параграфов · '+pct+'%';
var f=document.getElementById('prog-fill'); if(f) f.style.width=pct+'%';
Nav.forEach(function(it){ var a=document.getElementById('nav-'+it.id); if(a){ if(PROG[it.id]) a.classList.add('done'); else a.classList.remove('done'); } });
document.querySelectorAll('[data-mark]').forEach(function(m){ if(PROG[m.getAttribute('data-mark')]) m.classList.add('done'); });
}
function markRead(id){
if(PROG[id]) return;
PROG[id]=true; saveLocal();
if(READ_IDS.indexOf(id)!==-1) markServerRead(id);
addXp(5,id); refreshProgress(); popup('+5 XP · отмечено');
}
document.querySelectorAll('[data-mark]').forEach(function(m){
m.querySelector('.btn').addEventListener('click', function(){ markRead(m.getAttribute('data-mark')); });
});
function loadServer(){
var tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok){ refreshProgress(); return; }
fetch('/api/textbooks/'+_TB_SLUG,{headers:{'Authorization':'Bearer '+tok}}).then(function(r){return r.ok?r.json():null;}).then(function(d){
if(d&&d.progress&&d.progress.read){ d.progress.read.forEach(function(k){ _marked.add(k); PROG[k]=true; }); saveLocal(); }
refreshProgress();
}).catch(function(){ refreshProgress(); });
}
/* scrollspy */
(function(){
var secs = Nav.map(function(it){return document.getElementById(it.id);}).filter(Boolean);
function onScroll(){
var y = window.scrollY + 90, cur = secs[0];
secs.forEach(function(s){ if(s.offsetTop<=y) cur=s; });
Nav.forEach(function(it){ var a=document.getElementById('nav-'+it.id); if(a) a.classList.toggle('active', cur && cur.id===it.id); });
}
window.addEventListener('scroll', onScroll, {passive:true}); onScroll();
})();
/* ===== WIDGETS ===== */
/* §1 element lookup */
var EL = {
H:[1,'Водород'],He:[2,'Гелий'],Li:[3,'Литий'],Be:[4,'Бериллий'],B:[5,'Бор'],C:[6,'Углерод'],
N:[7,'Азот'],O:[8,'Кислород'],F:[9,'Фтор'],Ne:[10,'Неон'],Na:[11,'Натрий'],Mg:[12,'Магний'],
Al:[13,'Алюминий'],Si:[14,'Кремний'],P:[15,'Фосфор'],S:[16,'Сера'],Cl:[17,'Хлор'],Ar:[18,'Аргон'],
K:[19,'Калий'],Ca:[20,'Кальций'],Fe:[26,'Железо'],Cu:[29,'Медь'],Zn:[30,'Цинк'],Ag:[47,'Серебро'],Ba:[56,'Барий']
};
(function(){
var grid=document.getElementById('el-grid'), info=document.getElementById('el-info'); if(!grid) return;
Object.keys(EL).forEach(function(s){
var ar = C.arOf?C.arOf(s):'';
var c=document.createElement('div'); c.className='el-cell'; c.dataset.s=s;
c.innerHTML='<span class="z">'+EL[s][0]+'</span><span class="s">'+s+'</span><span class="a">'+ar+'</span>';
c.addEventListener('click',function(){
grid.querySelectorAll('.el-cell').forEach(function(x){x.classList.remove('on');}); c.classList.add('on');
info.innerHTML='<b>'+EL[s][1]+'</b> ('+s+') · порядковый номер Z = '+EL[s][0]+' · A_r = '+ar;
});
grid.appendChild(c);
});
})();
function bindOpts(trId, okText){
var tr=document.querySelector('[data-tr="'+trId+'"]'); if(!tr) return;
var fb=tr.querySelector('.tr-fb');
tr.querySelectorAll('.opt').forEach(function(o){
o.addEventListener('click',function(){
var ok = o.hasAttribute('data-ok') || (okText && o.textContent.trim()===okText);
tr.querySelectorAll('.opt').forEach(function(x){x.style.pointerEvents='none';});
o.classList.add(ok?'ok':'bad'); fb.className='tr-fb show '+(ok?'ok':'bad');
fb.textContent = ok?'Верно! +3 XP':'Неверно.';
if(!ok){ tr.querySelectorAll('.opt').forEach(function(x){ if(x.hasAttribute('data-ok')||(okText&&x.textContent.trim()===okText)) x.classList.add('ok'); }); }
else addXp(3,trId);
});
});
}
bindOpts('t1','в 2 раза');
bindOpts('t4',null);
/* §2 Mr calculator */
(function(){
var inp=document.getElementById('mr-in'), out=document.getElementById('mr-out'), go=document.getElementById('mr-go'); if(!inp) return;
function calc(){
var f=inp.value.trim(); var cnt=C.elementCounts?C.elementCounts(f):null; var mr=C.molarMass?C.molarMass(f):NaN;
if(!cnt||isNaN(mr)){ out.className='out bad'; out.textContent='Не удалось разобрать формулу. Проверьте символы элементов.'; return; }
var terms=Object.keys(cnt).map(function(e){ return (C.arOf?C.arOf(e):'?')+'·'+cnt[e]; });
out.className='out ok';
out.innerHTML='<b>M_r('+f+') = '+C.fmt(mr)+'</b><br><span class="bd">'+
Object.keys(cnt).map(function(e){return e+': A_r='+(C.arOf?C.arOf(e):'?')+' × '+cnt[e];}).join(' &nbsp;|&nbsp; ')+
'<br>Σ = '+terms.join(' + ')+' = '+C.fmt(mr)+'</span>';
}
go.addEventListener('click',calc);
inp.addEventListener('keydown',function(e){ if(e.key==='Enter') calc(); });
document.querySelectorAll('.mr-ex').forEach(function(b){ b.addEventListener('click',function(){ inp.value=b.dataset.f; calc(); }); });
calc();
})();
/* §3 portion */
(function(){
var sub=document.getElementById('port-sub'), rng=document.getElementById('port-n'), nv=document.getElementById('port-nv'), out=document.getElementById('port-out'); if(!sub) return;
var M={H2O:18,O2:32,CO2:44,NaCl:58.5};
function rr(v){ return (Math.round(v*100)/100).toString().replace('.',','); }
function upd(){
var n=parseFloat(rng.value), s=sub.value, m=n*M[s], N=n*6.02;
nv.textContent=n.toFixed(1).replace('.',',');
out.innerHTML='<span class="bd">n = '+n.toFixed(1).replace('.',',')+' моль<br>'+
'm = n·M = '+n.toFixed(1).replace('.',',')+' · '+String(M[s]).replace('.',',')+' = <b>'+rr(m)+' г</b><br>'+
'N = n·N_A = '+rr(N)+'·10²³ ≈ <b>'+rr(N)+'·10²³ частиц</b></span>';
}
sub.addEventListener('change',upd); rng.addEventListener('input',upd); upd();
})();
/* §4 avogadro */
(function(){
var rng=document.getElementById('av-n'), nv=document.getElementById('av-nv'), out=document.getElementById('av-out'); if(!rng) return;
function upd(){ var n=parseFloat(rng.value), N=n*6.02; nv.textContent=n.toFixed(2).replace('.',',');
out.innerHTML='<span class="bd">N = n · N_A = '+n.toFixed(2).replace('.',',')+' · 6,02·10²³ = <b>'+(Math.round(N*100)/100).toString().replace('.',',')+'·10²³ частиц</b></span>';
}
rng.addEventListener('input',upd); upd();
})();
/* §5 M + gas volume */
(function(){
var inp=document.getElementById('m5-in'), out=document.getElementById('m5-out'), go=document.getElementById('m5-go'); if(!inp) return;
function calc(){ var f=inp.value.trim(), mr=C.molarMass?C.molarMass(f):NaN;
if(isNaN(mr)){ out.className='out bad'; out.textContent='Не удалось разобрать формулу.'; return; }
out.className='out ok';
out.innerHTML='<span class="bd">M('+f+') = <b>'+C.fmt(mr)+' г/моль</b><br>1 моль газа при н.у. занимает <b>22,4 л</b>.<br>Плотность газа ≈ M/22,4 = '+(Math.round(mr/22.4*1000)/1000).toString().replace('.',',')+' г/л</span>';
}
go.addEventListener('click',calc); inp.addEventListener('keydown',function(e){if(e.key==='Enter')calc();}); calc();
})();
/* §6 mole triangle */
(function(){
var mount=document.getElementById('mt-mount'), sub=document.getElementById('mt-sub'); if(!mount||!C.moleTriangle) return;
var api=C.moleTriangle(mount,{});
if(sub) sub.addEventListener('change',function(){
var f=sub.value; if(!f) return;
var m=C.molarMass(f); if(!isNaN(m)&&api&&api.set) api.set('M',m);
});
})();
/* §7 universal gas calc */
(function(){
var sub=document.getElementById('g7-sub'), key=document.getElementById('g7-key'), val=document.getElementById('g7-val'), go=document.getElementById('g7-go'), out=document.getElementById('g7-out'); if(!sub) return;
var Vm=22.4, NA=6.02;
function calc(){
var f=sub.value, M=C.molarMass(f), k=key.value, x=parseFloat(val.value.replace(',','.'));
if(isNaN(x)){ out.className='out bad'; out.textContent='Введите число.'; return; }
var n;
if(k==='n') n=x; else if(k==='m') n=x/M; else if(k==='V') n=x/Vm; else n=x/NA;
var m=n*M, V=n*Vm, N=n*NA;
function r(v){ return (Math.round(v*1000)/1000).toString().replace('.',','); }
out.className='out ok';
out.innerHTML='<span class="bd">M('+f+')='+M+' г/моль<br>n = <b>'+r(n)+' моль</b><br>m = <b>'+r(m)+' г</b><br>V(н.у.) = <b>'+r(V)+' л</b><br>N = <b>'+r(N)+'·10²³ частиц</b></span>';
}
go.addEventListener('click',calc); val.addEventListener('keydown',function(e){if(e.key==='Enter')calc();}); calc();
})();
/* §8 balancer */
(function(){
var pick=document.getElementById('bal-pick'), mount=document.getElementById('bal-mount'); if(!pick||!C.equationBalancer) return;
function build(){
var parts=pick.value.split('|'); var skel=parts[0]; var sol=parts[1].split(',').map(Number);
C.equationBalancer(mount,{skeleton:skel, solution:sol});
}
pick.addEventListener('change',build); build();
})();
/* §9 stoichiometry step solver */
var ST = [
{ eq:'2H₂ + O₂ → 2H₂O', given:'Дано: m(H₂) = 4 г. Найти m(H₂O).',
steps:['M(H₂)=2 г/моль, M(H₂O)=18 г/моль.','n(H₂) = m/M = 4/2 = 2 моль.','По уравнению n(H₂):n(H₂O) = 2:2 = 1:1 → n(H₂O)=2 моль.','m(H₂O) = n·M = 2·18 = 36 г. Ответ: 36 г.'] },
{ eq:'CaCO₃ → CaO + CO₂↑', given:'Дано: m(CaCO₃) = 100 г. Найти V(CO₂) при н.у.',
steps:['M(CaCO₃)=100 г/моль.','n(CaCO₃) = 100/100 = 1 моль.','n(CaCO₃):n(CO₂) = 1:1 → n(CO₂)=1 моль.','V(CO₂) = n·Vm = 1·22,4 = 22,4 л. Ответ: 22,4 л.'] },
{ eq:'Zn + 2HCl → ZnCl₂ + H₂↑', given:'Дано: n(Zn) = 0,5 моль. Найти V(H₂) при н.у.',
steps:['n(Zn):n(H₂) = 1:1 → n(H₂)=0,5 моль.','V(H₂) = n·Vm = 0,5·22,4 = 11,2 л. Ответ: 11,2 л.'] }
];
(function(){
var pick=document.getElementById('st-pick'), out=document.getElementById('st-out'), bStep=document.getElementById('st-step'), bAll=document.getElementById('st-all'); if(!pick) return;
ST.forEach(function(p,i){ var o=document.createElement('option'); o.value=i; o.textContent=p.eq; pick.appendChild(o); });
var cur=0, shown=0;
function render(){
var p=ST[cur];
var html='<b>'+p.eq+'</b><br><span style="color:var(--muted)">'+p.given+'</span><div style="margin-top:8px">';
for(var i=0;i<shown;i++) html+='<div class="exa" style="margin-top:6px"><div class="step">'+p.steps[i]+'</div></div>';
if(shown===0) html+='<span style="color:var(--muted)">Нажмите «Следующий шаг», чтобы решать пошагово.</span>';
html+='</div>'; out.className='out'; out.innerHTML=html;
if(shown>=p.steps.length){ out.className='out ok'; }
}
pick.addEventListener('change',function(){ cur=+pick.value; shown=0; render(); });
bStep.addEventListener('click',function(){ if(shown<ST[cur].steps.length){ shown++; render(); } });
bAll.addEventListener('click',function(){ shown=ST[cur].steps.length; render(); });
render();
})();
/* ===== BOSS ===== */
var BOSS = [
{ q:'Чему равна молярная масса серной кислоты $\\text{H}_2\\text{SO}_4$? (г/моль)', ans:98, tol:0.5, hint:'M = 2·1 + 32 + 4·16 = 98.' },
{ q:'Сколько моль вещества в $80$ г $\\text{NaOH}$ ($M=40$ г/моль)?', ans:2, tol:0.05, hint:'n = m/M = 80/40 = 2.' },
{ q:'Какой объём (л, н.у.) занимают $3$ моль кислорода $\\text{O}_2$?', ans:67.2, tol:0.3, hint:'V = n·Vm = 3·22,4 = 67,2.' },
{ q:'Сколько молекул в $2$ моль воды? Ответ в виде коэффициента при ·10²³.', ans:12.04, tol:0.1, hint:'N = n·N_A = 2·6,02 = 12,04 (·10²³).' }
];
var BKEY='chemistry8_intro_boss', BACH='chemistry8_intro_ach';
function bossState(){ try{ return JSON.parse(localStorage.getItem(BKEY)||'{}')||{}; }catch(e){ return {}; } }
function bossSave(s){ try{ localStorage.setItem(BKEY,JSON.stringify(s)); }catch(e){} }
(function(){
var cont=document.getElementById('boss-cont'); if(!cont) return;
var st=bossState();
BOSS.forEach(function(b,i){
var solved=!!st[i];
var d=document.createElement('div'); d.className='bcard'+(solved?' solved':''); d.id='b'+i;
d.innerHTML='<div class="q"><b>Задача '+(i+1)+'.</b> '+b.q+'</div>'+
'<div class="row"><input type="text" inputmode="decimal" placeholder="ответ" style="width:120px;font-family:var(--mono)"'+(solved?' value="'+b.ans+'" disabled':'')+'>'+
'<button class="btn primary go"'+(solved?' disabled':'')+'>Ответить</button><button class="btn hint">Подсказка</button></div>'+
'<div class="hintbox">'+b.hint+'</div>'+
'<div class="fb'+(solved?' show ok':'')+'">'+(solved?'Решено! +10 XP':'')+'</div>';
cont.appendChild(d);
var inp=d.querySelector('input'), go=d.querySelector('.go'), hint=d.querySelector('.hint'), hb=d.querySelector('.hintbox'), fb=d.querySelector('.fb');
hint.addEventListener('click',function(){ hb.classList.toggle('show'); });
if(!solved) go.addEventListener('click',function(){
var v=parseFloat((inp.value||'').replace(',','.'));
if(isNaN(v)){ fb.className='fb show bad'; fb.textContent='Введите число.'; return; }
if(Math.abs(v-b.ans)<=b.tol){
fb.className='fb show ok'; fb.textContent='Верно! +10 XP'; d.classList.add('solved'); go.disabled=true; inp.disabled=true;
var s=bossState(); if(!s[i]){ s[i]=true; bossSave(s); addXp(10,'boss'+i); updateBoss(s); }
} else { fb.className='fb show bad'; fb.textContent='Не то. Проверь расчёт и попробуй снова.'; }
});
inp.addEventListener('keydown',function(e){ if(e.key==='Enter'&&!go.disabled) go.click(); });
});
updateBoss(st);
if(window.renderMathInElement) try{renderMathInElement(cont);}catch(e){}
})();
function updateBoss(s){
var won=0; for(var k in s) if(s[k]) won++;
var lab=document.getElementById('boss-lab'), fill=document.getElementById('boss-fill');
if(lab) lab.textContent='Решено: '+won+' / '+BOSS.length;
if(fill) fill.style.width=Math.round(won*100/BOSS.length)+'%';
if(won>=BOSS.length && localStorage.getItem(BACH)!=='1'){
localStorage.setItem(BACH,'1'); addXp(30,'ach');
var cta=document.getElementById('boss-cta'); if(cta) cta.classList.add('show');
popup('Ачивка «Счёт в химии»! +30 XP');
try{ if(window.confetti) confetti({particleCount:180,spread:100,origin:{y:.6}}); }catch(e){}
} else if(won>=BOSS.length){ var c=document.getElementById('boss-cta'); if(c) c.classList.add('show'); }
}
/* init */
loadLocal(); refreshXp(); refreshProgress(); loadServer();
window.addEventListener('focus',loadServer);
}); /* end DOMContentLoaded */
</script>
</body>