merge: feature/lab-content-engine → master

Контент-движок лаборатории (фазы 0-5): LabRegistry, data-driven регистрация,
вынос тел в labs-bodies.html, ленивая загрузка кода, БД-каталог lab_sims + API +
админка, курикулумные связи lab_sim_links + двусторонняя навигация.
Плюс накопленная работа параллельных сессий (chemistry-8, phys7, biochem, optics).

Разрешение конфликтов: frontend/lab.html — версия feature (контент-движок);
opticsbench.js / seed_biochem_challenges.js / BIOCHEM_UPGRADE.md /
biochem-pathways-plan.md — версия master (более свежая работа парал. сессий).

Тесты: 160, 157 pass, 3 fail (pre-existing baseline auth.test.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 17:53:58 +03:00
52 changed files with 13088 additions and 4605 deletions
+305
View File
@@ -0,0 +1,305 @@
/* gen_chem8_skeletons.js — генерирует каркасы 7 глав «Химия 8» (Phase 0).
* Запуск: node backend/scripts/gen_chem8_skeletons.js
* Выход: frontend/textbooks/chemistry_8_intro.html, _ch1.html ... _ch6.html
*
* Каркас = валидная брендированная страница: header (водяной знак), hero,
* оглавление § (read-only), баннер «в разработке», ссылка назад в хаб, тема.
* Полный интерактивный SPA-контент каждой главы добавляется в Phase 1–6
* (файлы перезаписываются), пока скелет обеспечивает навигацию и структуру.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
const P = (t, n) => ({ t, n }); // параграф
const NOTE = (note) => ({ note }); // лаб. опыт / практическая работа
const CHAPTERS = [
{
file: 'chemistry_8_intro.html', slug: 'chemistry-8-intro',
kicker: 'Вводный раздел', title: 'Количественные понятия в химии',
range: '§ 19', wm: 'mol',
color: { p:'#d97706', d:'#b45309', l:'#fbbf24', soft:'#fef3c7', bgd:'#1c1410', cardd:'#271c14', textd:'#fef3c7' },
items: [
P('§ 1', 'Атомы. Химические элементы. Относительная атомная масса'),
P('§ 2', 'Молекулы. Простые и сложные вещества. Химические формулы. Относительная молекулярная масса'),
P('§ 3', 'Химическое количество вещества'),
P('§ 4', 'Моль — единица химического количества вещества. Постоянная Авогадро'),
P('§ 5', 'Молярная масса. Молярный объём газов'),
P('§ 6', 'Вычисление химического количества вещества по его массе и массы вещества по его химическому количеству'),
P('§ 7', 'Вычисление химического количества газа по его объёму и объёма газа по его химическому количеству'),
NOTE('Практическая работа 1. Химическое количество вещества'),
P('§ 8', 'Химические реакции'),
P('§ 9', 'Количественные расчёты по уравнениям химических реакций')
]
},
{
file: 'chemistry_8_ch1.html', slug: 'chemistry-8-ch1',
kicker: 'Глава 1', title: 'Важнейшие классы неорганических соединений',
range: '§ 1023', wm: 'OH',
color: { p:'#0d9488', d:'#0f766e', l:'#14b8a6', soft:'#ccfbf1', bgd:'#0c1a18', cardd:'#102825', textd:'#ccfbf1' },
items: [
P('§ 10', 'Оксиды. Состав и классификация оксидов'),
P('§ 11', 'Химические свойства оксидов'),
P('§ 12', 'Получение и применение оксидов'),
P('§ 13', 'Кислоты. Состав и классификация кислот'),
P('§ 14', 'Химические свойства кислот'),
P('§ 15', 'Получение и применение кислот'),
P('§ 16', 'Основания'),
P('§ 17', 'Химические свойства оснований'),
P('§ 18', 'Получение и применение оснований'),
NOTE('Лабораторный опыт 1. Получение нерастворимого основания'),
NOTE('Практическая работа 2. Изучение реакции нейтрализации'),
P('§ 19', 'Соли. Состав и классификация солей'),
P('§ 20', 'Химические свойства солей'),
NOTE('Лабораторный опыт 2. Взаимодействие растворов солей с металлами'),
P('§ 21', 'Получение и применение солей'),
P('§ 22', 'Взаимосвязь между классами основных неорганических веществ'),
NOTE('Практическая работа 3. Решение экспериментальных задач'),
P('§ 23', 'Решение расчётных задач по теме «Основные классы неорганических соединений»')
]
},
{
file: 'chemistry_8_ch2.html', slug: 'chemistry-8-ch2',
kicker: 'Глава 2', title: 'Периодический закон и периодическая система химических элементов',
range: '§ 2428', wm: '№',
color: { p:'#4f46e5', d:'#4338ca', l:'#818cf8', soft:'#e0e7ff', bgd:'#12122b', cardd:'#1b1b3a', textd:'#e0e7ff' },
items: [
P('§ 24', 'Систематизация химических элементов'),
P('§ 25', 'Понятие об амфотерности'),
NOTE('Лабораторный опыт 3. Получение гидроксида цинка и изучение его амфотерных свойств'),
P('§ 26', 'Естественные семейства элементов'),
P('§ 27', 'Периодический закон Д. И. Менделеева'),
P('§ 28', 'Периодическая система химических элементов')
]
},
{
file: 'chemistry_8_ch3.html', slug: 'chemistry-8-ch3',
kicker: 'Глава 3', title: 'Строение атома и периодичность изменения свойств',
range: '§ 2935', wm: 'e',
color: { p:'#2563eb', d:'#1d4ed8', l:'#60a5fa', soft:'#dbeafe', bgd:'#0a1428', cardd:'#102137', textd:'#dbeafe' },
items: [
P('§ 29', 'Строение атома. Атомный номер химического элемента'),
P('§ 30', 'Массовое число атома. Нуклиды'),
P('§ 31', 'Изотопы. Явление радиоактивности'),
P('§ 32', 'Состояние электронов в атоме. Электронное облако. Атомная орбиталь'),
P('§ 33', 'Строение электронных оболочек атомов'),
P('§ 34', 'Периодичность изменения свойств атомов химических элементов'),
P('§ 35', 'Характеристика химического элемента по его положению в периодической системе')
]
},
{
file: 'chemistry_8_ch4.html', slug: 'chemistry-8-ch4',
kicker: 'Глава 4', title: 'Химическая связь',
range: '§ 3641', wm: 'H₂O',
color: { p:'#059669', d:'#047857', l:'#34d399', soft:'#d1fae5', bgd:'#0a1a12', cardd:'#10271c', textd:'#d1fae5' },
items: [
P('§ 36', 'Природа химической связи'),
P('§ 37', 'Ковалентная связь'),
P('§ 38', 'Неполярная и полярная ковалентная связь. Электроотрицательность'),
NOTE('Лабораторный опыт 4. Составление моделей молекул'),
P('§ 39', 'Ионная связь'),
P('§ 40', 'Металлическая связь. Межмолекулярное взаимодействие'),
P('§ 41', 'Кристаллическое состояние вещества')
]
},
{
file: 'chemistry_8_ch5.html', slug: 'chemistry-8-ch5',
kicker: 'Глава 5', title: 'Окислительно-восстановительные реакции',
range: '§ 4245', wm: 'O₂',
color: { p:'#ea580c', d:'#c2410c', l:'#fb923c', soft:'#ffedd5', bgd:'#1c1208', cardd:'#2a1c10', textd:'#ffedd5' },
items: [
P('§ 42', 'Степень окисления'),
P('§ 43', 'Процессы окисления и восстановления'),
P('§ 44', 'Окислительно-восстановительные реакции'),
P('§ 45', 'Окислительно-восстановительные реакции вокруг нас')
]
},
{
file: 'chemistry_8_ch6.html', slug: 'chemistry-8-ch6',
kicker: 'Глава 6', title: 'Растворы',
range: '§ 4652', wm: 'aq',
color: { p:'#0891b2', d:'#0e7490', l:'#22d3ee', soft:'#cffafe', bgd:'#08191c', cardd:'#10282d', textd:'#cffafe' },
items: [
P('§ 46', 'Смеси веществ'),
P('§ 47', 'Растворение веществ в воде'),
P('§ 48', 'Характеристики растворимости веществ'),
P('§ 49', 'Качественные характеристики состава растворов'),
P('§ 50', 'Количественные характеристики растворённых веществ. Массовая доля растворённого вещества'),
P('§ 51', 'Молярная концентрация растворённых веществ'),
NOTE('Практическая работа 4. Приготовление раствора с заданной массовой долей и молярной концентрацией'),
P('§ 52', 'Вода и растворы в жизни и деятельности человека')
]
}
];
function esc(s) {
return String(s).replace(/[&<>]/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;' }[c]));
}
function outlineHtml(items) {
return items.map(it => {
if (it.note) {
return ' <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>' + esc(it.note) + '</span></li>';
}
return ' <li class="ol-para"><span class="ol-num">' + esc(it.t) + '</span><span class="ol-name">' + esc(it.n) + '</span></li>';
}).join('\n');
}
function pageHtml(ch) {
const c = ch.color;
const wmHeader = ch.kicker.toUpperCase();
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · ${esc(ch.kicker)} · «${esc(ch.title)}»</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 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 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;
--pri:${c.p}; --pri-d:${c.d}; --pri-l:${c.l}; --pri-soft:${c.soft};
--sh:0 4px 16px rgba(0,0,0,.06); --sh-h:0 12px 32px rgba(0,0,0,.12);
}
html.dark{ --bg:${c.bgd}; --card:${c.cardd}; --text:${c.textd}; --muted:#a8a29e; --border:#3a3026; --pri-soft:rgba(0,0,0,.2); }
*{margin:0;padding:0;box-sizing:border-box}
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}
.hdr{position:relative;background:linear-gradient(110deg,${c.d} 0%,${c.p} 55%,${c.l} 100%);color:#fff;padding:34px 24px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
.hdr::before{content:'${wmHeader}';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}
.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-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}
.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}
.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)}
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
</style>
</head>
<body>
<header class="hdr">
<div class="hdr-inner">
<a href="/textbook/chemistry-8" class="hdr-back">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
К разделам
</a>
<div>
<div class="hdr-kicker">${esc(ch.kicker)} &middot; ${esc(ch.range)}</div>
<h1>${esc(ch.title)}</h1>
</div>
<div class="hdr-side">
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span id="theme-lab">Тёмная</span>
</button>
</div>
</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>
<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>
<ul class="ol-list">
${outlineHtml(ch.items)}
</ul>
</main>
<footer class="foot">
Интерактивный учебник «Химия — 8 класс» &middot; ${esc(ch.kicker)} &middot; LearnSpace
</footer>
<script>
'use strict';
const _TB_SLUG = '${ch.slug}';
(function(){
var saved = localStorage.getItem('chemistry8_theme') || localStorage.getItem('theme') || 'light';
if (saved === 'dark') document.documentElement.classList.add('dark');
var lab = document.getElementById('theme-lab');
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 ? 'Светлая' : 'Тёмная';
});
})();
</script>
</body>
</html>
`;
}
// --force перезапишет уже существующие файлы; по умолчанию — пропускаем
// готовые (наполненные в фазах) страницы, чтобы не затереть контент.
const FORCE = process.argv.includes('--force');
let count = 0, skipped = 0;
for (const ch of CHAPTERS) {
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, 'written,', skipped, 'skipped');
@@ -8,6 +8,9 @@ function list(req, res) {
let where = '1=1'; let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); } if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); } if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(` const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
@@ -0,0 +1,56 @@
-- Chemistry 8 hub migration.
-- Creates chemistry-8 as a full hub textbook (intro + 6 chapters) in the style of physics-9:
-- chemistry-8 (hub, html_path = chemistry_8_hub.html)
-- chemistry-8-intro (Количественные понятия, §§1–9) → chemistry_8_intro.html
-- chemistry-8-ch1 (Важнейшие классы соединений, §§1023) → chemistry_8_ch1.html
-- chemistry-8-ch2 (Периодический закон и ПСХЭ, §§2428) → chemistry_8_ch2.html
-- chemistry-8-ch3 (Строение атома, §§29–35) → chemistry_8_ch3.html
-- chemistry-8-ch4 (Химическая связь, §§36–41) → chemistry_8_ch4.html
-- chemistry-8-ch5 (ОВР, §§4245) → chemistry_8_ch5.html
-- chemistry-8-ch6 (Растворы, §§46–52) → chemistry_8_ch6.html
--
-- Source: Шиманович И. Е., Красицкий В. А., Сечко О. И., Хвалюк В. Н.,
-- «Химия 8», Народная асвета, 2018. Контент авторский (наш).
-- Author left empty per project policy.
-- 1. Insert the parent chemistry-8 hub row (does not exist yet in the catalog).
INSERT INTO textbooks
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug)
VALUES
('chemistry-8', 'chemistry', 8, 'Химия — 8 класс',
'',
'Полный курс химии за 8 класс: количественные понятия (моль, молярная масса и объём, расчёты по уравнениям), важнейшие классы неорганических соединений, периодический закон и строение атома, химическая связь, окислительно-восстановительные реакции, растворы. 7 разделов, 52 параграфа, 4 лабораторных опыта, 4 практические работы.',
'chemistry_8_hub.html', 52, 'amber', 8, 1, NULL);
-- 2. Insert the 7 children (intro section + 6 chapters).
INSERT INTO textbooks
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug)
VALUES
('chemistry-8-intro', 'chemistry', 8, 'Химия 8 · Количественные понятия в химии',
'',
'§§1–9: атомы и химические элементы, простые и сложные вещества, химическое количество вещества, моль и постоянная Авогадро, молярная масса и молярный объём газов, расчёты по массе/объёму и по уравнениям реакций. Практическая работа 1.',
'chemistry_8_intro.html', 9, 'amber', 1, 1, 'chemistry-8'),
('chemistry-8-ch1', 'chemistry', 8, 'Химия 8 · Важнейшие классы неорганических соединений',
'',
'§§10–23: оксиды, кислоты, основания и соли — состав, классификация, химические свойства, получение и применение; генетическая связь между классами. 2 лабораторных опыта, 2 практические работы.',
'chemistry_8_ch1.html', 14, 'teal', 2, 1, 'chemistry-8'),
('chemistry-8-ch2', 'chemistry', 8, 'Химия 8 · Периодический закон и периодическая система',
'',
'§§24–28: систематизация элементов, амфотерность, естественные семейства элементов, периодический закон Д. И. Менделеева и строение периодической системы. Лабораторный опыт 3.',
'chemistry_8_ch2.html', 5, 'indigo', 3, 1, 'chemistry-8'),
('chemistry-8-ch3', 'chemistry', 8, 'Химия 8 · Строение атома',
'',
'§§29–35: строение атома и атомный номер, массовое число и нуклиды, изотопы и радиоактивность, электронное облако и атомная орбиталь, строение электронных оболочек, периодичность свойств, характеристика элемента по положению в ПС.',
'chemistry_8_ch3.html', 7, 'blue', 4, 1, 'chemistry-8'),
('chemistry-8-ch4', 'chemistry', 8, 'Химия 8 · Химическая связь',
'',
'§§36–41: природа химической связи, ковалентная связь (неполярная и полярная, электроотрицательность), ионная и металлическая связь, межмолекулярное взаимодействие, кристаллическое состояние вещества. Лабораторный опыт 4.',
'chemistry_8_ch4.html', 6, 'green', 5, 1, 'chemistry-8'),
('chemistry-8-ch5', 'chemistry', 8, 'Химия 8 · Окислительно-восстановительные реакции',
'',
'§§42–45: степень окисления, процессы окисления и восстановления, окислительно-восстановительные реакции и метод электронного баланса, ОВР вокруг нас.',
'chemistry_8_ch5.html', 4, 'orange', 6, 1, 'chemistry-8'),
('chemistry-8-ch6', 'chemistry', 8, 'Химия 8 · Растворы',
'',
'§§46–52: смеси веществ, растворение веществ в воде, характеристики растворимости, качественные и количественные характеристики состава растворов, массовая доля и молярная концентрация, вода и растворы в жизни человека. Практическая работа 4.',
'chemistry_8_ch6.html', 7, 'cyan', 7, 1, 'chemistry-8');
@@ -0,0 +1,65 @@
-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4.
-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги,
-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для
-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend).
--
-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids
-- на уровне API, поэтому существующая логика lab.html не ломается.
CREATE TABLE IF NOT EXISTS lab_sims (
id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...)
cat TEXT NOT NULL, -- math | phys | chem | bio | game
title TEXT NOT NULL,
subject TEXT, -- курикулум (Фаза 5), напр. 'physics'
grade INTEGER, -- класс (Фаза 5)
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге
featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая»
tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order);
-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS).
INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES
('graph', 'math', 'График функции', 1),
('graphtransform', 'math', 'Трансформации графиков', 2),
('geometry', 'math', 'Планиметрия', 3),
('triangle', 'math', 'Геометрия треугольника', 4),
('quadratic', 'math', 'Корни квадратного уравнения', 5),
('stereo', 'math', 'Стереометрия 3D', 6),
('probability', 'math', 'Теория вероятностей', 7),
('trigcircle', 'math', 'Тригонометрическая окружность', 8),
('normaldist', 'math', 'Нормальное распределение', 9),
('projectile', 'phys', 'Бросок тела', 10),
('pendulum', 'phys', 'Маятник', 11),
('collision', 'phys', 'Столкновение шаров', 12),
('emfield', 'phys', 'Электромагнитные поля', 13),
('circuit', 'phys', 'Электрические цепи', 14),
('hydrostatics', 'phys', 'Гидростатика', 15),
('dynamics', 'phys', 'Динамика', 16),
('opticsbench', 'phys', 'Оптическая скамья', 17),
('isoprocess', 'phys', 'Изопроцессы', 18),
('waves', 'phys', 'Волны и звук', 19),
('radioactive', 'phys', 'Радиоактивный распад', 20),
('race', 'phys', 'Гонка с задачами', 21),
('heatengine', 'phys', 'Тепловые двигатели', 22),
('logic', 'phys', 'Логические схемы', 23),
('molphys', 'chem', 'Молекулярная физика', 24),
('chemistry', 'chem', 'Химические реакции', 25),
('equilibrium', 'chem', 'Химическое равновесие', 26),
('electrolysis', 'chem', 'Электролиз', 27),
('bohratom', 'chem', 'Атом Бора', 28),
('orbitals', 'chem', 'Молекулярные орбитали', 29),
('titration', 'chem', 'pH и кривая титрования', 30),
('chemsandbox', 'chem', 'Химическая песочница', 31),
('stoichiometry', 'chem', 'Стехиометрия', 32),
('crystal', 'chem', 'Кристаллическая решётка', 33),
('qualanalysis', 'chem', 'Качественный анализ', 34),
('periodic', 'chem', 'Периодическая таблица', 35),
('organic', 'chem', 'Органическая химия', 36),
('solutions', 'chem', 'Растворы', 37),
('celldivision', 'bio', 'Деление клетки', 38),
('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39),
('angrybirds', 'game', 'Angry Birds Physics', 40);
@@ -0,0 +1,24 @@
-- 043_lab_sim_links.sql — Контент-движок лаборатории, Фаза 5 (курикулумная привязка).
-- Связи симуляции с учебной программой: § учебника, тема, узел knowledge-map,
-- задача банка вопросов. Двусторонняя навигация (sim ↔ контент).
--
-- kind:
-- 'textbook' — ref_id = textbooks.slug
-- 'topic' — ref_id = topics.id (как текст)
-- 'kmap' — ref_id = id узла графа знаний (свободная строка)
-- 'question' — ref_id = questions.id (как текст)
-- label — необязательная человекочитаемая подпись (если не резолвится из БД).
CREATE TABLE IF NOT EXISTS lab_sim_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sim_id TEXT NOT NULL,
kind TEXT NOT NULL, -- textbook | topic | kmap | question
ref_id TEXT NOT NULL,
label TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (sim_id, kind, ref_id)
);
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_sim ON lab_sim_links (sim_id);
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_ref ON lab_sim_links (kind, ref_id);
+299
View File
@@ -0,0 +1,299 @@
'use strict';
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5).
*
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги. auth.
* PATCH /api/lab/sims/:id — enabled/featured/tags/subject/grade. admin.
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
* GET /api/lab/sims/:id/related — связанные § / темы / kmap / задачи. auth. (Ф5)
* POST /api/lab/sims/:id/links — добавить связь. admin. (Ф5)
* DELETE /api/lab/sims/:id/links/:linkId — удалить связь. admin. (Ф5)
* GET /api/lab/links?kind=&ref_id= — обратный поиск: какие симуляции привязаны
* к данному учебнику/теме. auth. (Ф5)
*
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
* корректно скрывать отключённые симуляции без правок фронта. */
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
router.use(authMiddleware);
/* ── helpers ───────────────────────────────────────────────────────────── */
function readModuleDisabled() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get();
return row ? row.value === '1' : false;
}
function readLegacyDisabledIds() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get();
try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); }
}
function writeLegacyDisabledIds(set) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify([...set]));
}
function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } }
function rowToSim(r) {
return {
id: r.id, cat: r.cat, title: r.title,
subject: r.subject || null, grade: r.grade != null ? r.grade : null,
sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured,
tags: parseTags(r.tags),
};
}
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
router.get('/sims', (_req, res) => {
let rows;
try {
rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
} catch (e) {
// Деградация вместо 500: если миграция lab_sims ещё не применена на этом
// инстансе (старый процесс/другая БД) — отдаём пустой каталог, чтобы админка
// не падала. Нужно применить миграцию и перезапустить сервер.
console.warn('[lab] lab_sims недоступна (нужна миграция/перезапуск):', e.message);
return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const sims = rows.map(r => {
const s = rowToSim(r);
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
s.enabled = s.enabled && !legacyDisabled.has(r.id);
return s;
});
res.json({ module_disabled: readModuleDisabled(), sims });
});
/* ── admin mutations ───────────────────────────────────────────────────────
ВАЖНО: НЕ используем blanket `router.use(requireRole('admin'))` — он применялся
бы и к ниже определённым READ-роутам Фазы 5 (/related, /links), которые должны
быть доступны любому авторизованному пользователю. Каждая мутация защищена
INLINE requireRole('admin') (так же видит route-auth линтер). */
/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */
router.patch('/sims/:id', requireRole('admin'), (req, res) => {
const id = String(req.params.id || '');
const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'симуляция не найдена' });
const b = req.body || {};
const sets = [];
const vals = [];
if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); }
if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); }
if (b.title !== undefined) {
const t = String(b.title).trim();
if (!t) return res.status(400).json({ error: 'пустой title' });
sets.push('title = ?'); vals.push(t);
}
if (b.cat !== undefined) {
if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' });
sets.push('cat = ?'); vals.push(b.cat);
}
if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); }
if (b.grade !== undefined) {
const g = b.grade === null || b.grade === '' ? null : Number(b.grade);
if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) {
return res.status(400).json({ error: 'grade должен быть 1..11 или null' });
}
sets.push('grade = ?'); vals.push(g);
}
if (b.tags !== undefined) {
if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' });
const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20);
sets.push('tags = ?'); vals.push(JSON.stringify(clean));
}
if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' });
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
// Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html.
if (b.enabled !== undefined) {
const set = readLegacyDisabledIds();
if (b.enabled) set.delete(id); else set.add(id);
writeLegacyDisabledIds(set);
}
const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
res.json({ ok: true, sim: rowToSim(updated) });
});
/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */
router.post('/sims/reorder', requireRole('admin'), (req, res) => {
const order = (req.body && req.body.order) || [];
if (!Array.isArray(order) || !order.length) {
return res.status(400).json({ error: 'order должен быть непустым массивом id' });
}
const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id));
for (const id of order) {
if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id });
}
const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?");
db.transaction(() => {
order.forEach((id, i) => upd.run(i + 1, id));
})();
res.json({ ok: true, count: order.length });
});
/* ════════════════════════════════════════════════════════════════════════
Курикулумная привязка (Фаза 5) — связи симуляции ↔ контент.
════════════════════════════════════════════════════════════════════════ */
// Безопасно прочитать связи симуляции (если таблицы ещё нет — пустой массив).
function readLinks(simId) {
try {
return db.prepare(
'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id'
).all(simId);
} catch (e) {
return null; // null => таблица недоступна (нужна миграция)
}
}
// Обогатить связь человекочитаемой меткой и навигационным href.
function decorateLink(l) {
const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null };
if (l.kind === 'textbook') {
const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id);
if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; }
out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id);
} else if (l.kind === 'topic') {
const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id));
if (tp) out.label = out.label || tp.name;
} else if (l.kind === 'question') {
out.href = null; // задачи открываются в банке вопросов отдельным контекстом
}
if (!out.label) out.label = l.kind + ':' + l.ref_id;
return out;
}
/* GET /api/lab/sims/:id/related → { sim, links:{ textbook:[], topic:[], kmap:[], question:[] } } */
router.get('/sims/:id/related', authMiddleware, (req, res) => {
const id = String(req.params.id || '');
const sim = db.prepare('SELECT id, title FROM lab_sims WHERE id = ?').get(id);
// sim может отсутствовать в lab_sims (если миграция 042 не применена) — не 404,
// т.к. связи всё равно могут существовать; вернём то, что есть.
const rows = readLinks(id);
if (rows === null) return res.json({ sim: sim || { id }, links: {}, needs_migration: true });
const links = { textbook: [], topic: [], kmap: [], question: [] };
for (const l of rows) {
const d = decorateLink(l);
(links[l.kind] || (links[l.kind] = [])).push(d);
}
res.json({ sim: sim || { id }, links });
});
/* GET /api/lab/links?kind=textbook&ref_id=algebra-8
→ { sims:[{id,title,cat,enabled}] } — какие (включённые) симуляции привязаны. */
router.get('/links', (req, res) => {
const kind = String(req.query.kind || '');
const refId = String(req.query.ref_id || '');
if (!LINK_KINDS.includes(kind) || !refId) {
return res.status(400).json({ error: 'kind и ref_id обязательны' });
}
let rows;
try {
rows = db.prepare(`
SELECT s.id, s.title, s.cat, s.enabled
FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id
WHERE l.kind = ? AND l.ref_id = ?
ORDER BY s.sort_order, s.id
`).all(kind, refId);
} catch (e) {
return res.json({ sims: [], needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const sims = rows
.map(r => ({ id: r.id, title: r.title, cat: r.cat, enabled: !!r.enabled && !legacyDisabled.has(r.id) }))
.filter(s => s.enabled); // наружу отдаём только доступные
res.json({ sims });
});
/* GET /api/lab/links/all?kind=textbook
→ { byRef: { <ref_id>: [{id,title,cat}] } } — пакетный обратный поиск для всех
ref_id данного типа за один запрос (избегаем N+1 на странице каталога учебников).
Отдаёт только включённые симуляции. */
router.get('/links/all', (req, res) => {
const kind = String(req.query.kind || '');
if (!LINK_KINDS.includes(kind)) {
return res.status(400).json({ error: 'неверный kind' });
}
let rows;
try {
rows = db.prepare(`
SELECT l.ref_id, s.id, s.title, s.cat, s.enabled, s.sort_order
FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id
WHERE l.kind = ?
ORDER BY s.sort_order, s.id
`).all(kind);
} catch (e) {
return res.json({ byRef: {}, needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const byRef = {};
for (const r of rows) {
if (!r.enabled || legacyDisabled.has(r.id)) continue;
(byRef[r.ref_id] || (byRef[r.ref_id] = [])).push({ id: r.id, title: r.title, cat: r.cat });
}
res.json({ byRef });
});
/* ── admin: управление связями ─────────────────────────────────────────── */
/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */
router.post('/sims/:id/links', requireRole('admin'), (req, res) => {
const simId = String(req.params.id || '');
if (!db.prepare('SELECT 1 FROM lab_sims WHERE id = ?').get(simId)) {
return res.status(404).json({ error: 'симуляция не найдена' });
}
const b = req.body || {};
const kind = String(b.kind || '');
const refId = String(b.ref_id || '').trim();
if (!LINK_KINDS.includes(kind)) return res.status(400).json({ error: 'неверный kind' });
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
// Валидация существования цели (мягкая — kmap/question произвольны).
if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) {
return res.status(404).json({ error: 'учебник не найден: ' + refId });
}
if (kind === 'topic') {
const tid = Number(refId);
if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) {
return res.status(404).json({ error: 'тема не найдена: ' + refId });
}
}
const label = b.label != null ? String(b.label).trim().slice(0, 200) || null : null;
try {
const info = db.prepare(
'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)'
).run(simId, kind, refId, label, req.user.id);
const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?')
.get(info.lastInsertRowid);
res.json({ ok: true, link: decorateLink(created) });
} catch (e) {
if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' });
throw e;
}
});
/* DELETE /api/lab/sims/:id/links/:linkId */
router.delete('/sims/:id/links/:linkId', requireRole('admin'), (req, res) => {
const simId = String(req.params.id || '');
const linkId = Number(req.params.linkId);
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId);
if (!info.changes) return res.status(404).json({ error: 'связь не найдена' });
res.json({ ok: true });
});
module.exports = router;
+2
View File
@@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks'); const textbookRoutes = require('./routes/textbooks');
const accessRoutes = require('./routes/access'); const accessRoutes = require('./routes/access');
const teacherStudentsRoutes = require('./routes/teacherStudents'); const teacherStudentsRoutes = require('./routes/teacherStudents');
const labRoutes = require('./routes/lab');
const { requestId, errorHandler } = require('./middleware/errorHandler'); const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes); app.use('/api/textbooks', textbookRoutes);
app.use('/api/access', accessRoutes); app.use('/api/access', accessRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */ /* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db'); const _featDb = require('./db/db');
+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 сбалансировано');
});
+236
View File
@@ -0,0 +1,236 @@
'use strict';
/*
* Полностраничная jsdom-проверка глав «Химия 8» (SPA на chem8_engine.js):
* выполняем реальный HTML + движок + виджеты, даём таймерам отработать, проверяем
* para-selector, активный §, монтаж виджетов — без ошибок скриптов.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM, VirtualConsole } = require('jsdom');
const ROOT = path.join(__dirname, '..', '..');
const readF = p => fs.readFileSync(path.join(ROOT, p), 'utf8');
const wait = ms => new Promise(r => setTimeout(r, ms));
function buildPage(file, widgetsSrc) {
let html = readF('frontend/textbooks/' + file);
const inl = {
'/js/biochem-core.js': readF('frontend/js/biochem-core.js'),
'/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'),
'/js/chem8_mol.js': readF('frontend/js/chem8_mol.js'),
[widgetsSrc]: readF('frontend/js' + widgetsSrc.replace('/js', '')),
'/js/chem8_engine.js': readF('frontend/js/chem8_engine.js')
};
html = html
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
Object.keys(inl).forEach(src => {
html = html.replace(new RegExp('<script src="' + src + '" defer><\\/script>'), () => '<script>\n' + inl[src] + '\n</script>');
});
return html;
}
async function loadDom(file, widgetsSrc) {
const errors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildPage(file, widgetsSrc), {
runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/',
beforeParse(w) { w.scrollTo = function () {}; }
});
await wait(180);
return { dom, errors, doc: dom.window.document };
}
/* ── Вводный раздел ── */
test('intro: SPA без ошибок, 11 карточек, §1 активен, виджеты', async () => {
const { doc, errors } = await loadDom('chemistry_8_intro.html', '/js/chem8_intro_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 11, '11 карточек');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p1', '§1 активен');
assert.ok(doc.querySelectorAll('#p1-el .el-cell').length > 10, 'карта элементов');
doc.defaultView.goTo('p6'); await wait(120);
assert.ok(doc.querySelector('#p6-mount .mtri'), 'треугольник §6');
});
/* ── Глава 1 ── */
test('ch1: SPA без ошибок, 15 карточек, §10 активен', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 15, '14 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p10', '§10 активен');
assert.ok(doc.querySelector('#p10-body .para-hero'), 'para-hero §10');
});
test('ch1: флагман-виджеты монтируются (классификатор, растворимость, ряд активности)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('p10'); await wait(120);
assert.ok(doc.querySelector('#c-ox-cls .cls-chip'), 'классификатор оксидов §10');
doc.defaultView.goTo('p13'); await wait(120);
assert.ok(doc.querySelector('#c-acid-ind .ind-strip'), 'индикатор §13');
doc.defaultView.goTo('p19'); await wait(120);
assert.ok(doc.querySelector('#c-salt-sol .sol-tab'), 'таблица растворимости §19');
doc.defaultView.goTo('p14'); await wait(120);
assert.ok(doc.querySelector('#c-acid-act .act-cell'), 'ряд активности §14');
});
test('ch1: тренажёр задач отрисован для §10', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
await wait(150);
assert.ok(doc.querySelectorAll('#navDotsp10 .nav-dot').length >= 4, 'навигация по задачам §10');
});
test('ch1: генетическая карта §22 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('p22'); await wait(120);
assert.ok(doc.querySelectorAll('#c-genetic .gm-edge').length >= 6, 'граф классов §22');
});
test('ch1: карта связей в финале главы монтируется (U6)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('final1'); await wait(120);
assert.ok(doc.querySelectorAll('#c-concept .gm-edge').length >= 3, 'карта связей понятий финала');
});
/* ── Глава 2 ── */
test('ch2: SPA без ошибок, 6 карточек, §24 активен, ПСХЭ', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 6, '5 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p24', '§24 активен');
await wait(120);
assert.ok(doc.querySelectorAll('#c-pt-metals .pt-cell').length > 80, 'ПСХЭ §24 (90 элементов)');
});
test('ch2: амфотерность §25 и семейства §26 монтируются', async () => {
const { doc } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js');
doc.defaultView.goTo('p25'); await wait(120);
assert.ok(doc.querySelector('#c-amph .amph-btn'), 'амфотерность §25');
doc.defaultView.goTo('p26'); await wait(120);
assert.ok(doc.querySelectorAll('#c-pt-fam .pt-cell').length > 80, 'ПСХЭ семейства §26');
});
/* ── Глава 3 ── */
test('ch3: SPA без ошибок, 8 карточек, §29 активен, модель атома', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 8, '7 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p29', '§29 активен');
await wait(120);
assert.ok(doc.querySelector('#c-atom .as-svg'), 'модель атома §29');
});
test('ch3: нуклид §30 и паспорт §35 монтируются', async () => {
const { doc } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js');
doc.defaultView.goTo('p30'); await wait(120);
assert.ok(doc.querySelector('#c-nuclide #nz'), 'калькулятор нуклида §30');
doc.defaultView.goTo('p35'); await wait(120);
assert.ok(doc.querySelectorAll('#c-passport .pt-cell').length > 80, 'ПСХЭ паспорта §35');
});
/* ── Глава 4 ── */
test('ch4: SPA без ошибок, 7 карточек, §36 активен, тип связи', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 7, '6 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p36', '§36 активен');
doc.defaultView.goTo('p37'); await wait(120);
assert.ok(doc.querySelector('#c-bond1 .bt-svg'), 'виджет типа связи §37');
doc.defaultView.goTo('p38'); await wait(120);
assert.ok(doc.querySelector('#c-bond2 .bt-out'), 'виджет полярности §38');
});
test('ch4: 3D-модели молекул §38 и решётки §41 монтируются (U4)', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
doc.defaultView.goTo('p38'); await wait(140);
assert.ok(doc.querySelector('#c-mol .mol-sel'), 'выбор молекулы §38');
assert.ok(doc.querySelector('#c-mol canvas'), 'canvas 3D-модели §38');
assert.ok(doc.querySelector('#c-mol .mol-info'), 'инфо о молекуле §38');
doc.defaultView.goTo('p41'); await wait(140);
assert.ok(doc.querySelector('#c-lattice .lat-sel'), 'выбор решётки §41');
assert.ok(doc.querySelector('#c-lattice canvas'), 'canvas решётки §41');
});
/* ── Глава 5 ── */
test('ch5: SPA без ошибок, 5 карточек, §42 активен, с.о. и баланс', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch5.html', '/js/chem8_ch5_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 5, '4 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p42', '§42 активен');
await wait(120);
assert.ok(doc.querySelector('#c-ox .ox-out'), 'калькулятор с.о. §42');
doc.defaultView.goTo('p44'); await wait(120);
assert.ok(doc.querySelector('#c-redox-pick option'), 'электронный баланс §44');
});
/* ── Глоссарий (U2/Phase 8) ── */
test('glossary: кнопка, модалка, авто-подсветка терминов', async () => {
const src = readF('frontend/js/chem8_glossary.js');
const dom = new JSDOM('<!DOCTYPE html><body><div class="card-body"><p>Оксид — это сложное вещество. Кислота реагирует с основанием в реакции нейтрализации.</p></div></body>',
{ runScripts: 'outside-only', pretendToBeVisual: true, url: 'http://localhost/' });
new Function('window', src)(dom.window);
await wait(20);
const doc = dom.window.document;
assert.ok(dom.window.Chem8Glossary, 'window.Chem8Glossary определён');
assert.ok(Object.keys(dom.window.Chem8Glossary.terms).length > 40, '>40 терминов');
assert.ok(doc.querySelector('.gl-fab'), 'плавающая кнопка глоссария');
// авто-подсветка терминов в .card-body
assert.ok(doc.querySelectorAll('.card-body .gloss').length >= 2, 'термины подсвечены: ' + doc.querySelectorAll('.gloss').length);
// открытие модалки
dom.window.Chem8Glossary.open();
assert.ok(doc.querySelector('.gl-modal.show'), 'модалка открыта');
assert.ok(doc.querySelectorAll('.gl-modal .gl-item').length > 40, 'список терминов в модалке');
});
/* ── Хаб: финал курса (Phase 7) ── */
function buildHub() {
let html = readF('frontend/textbooks/chemistry_8_hub.html');
return html
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
}
async function loadHub() {
const errors = []; const vc = new VirtualConsole(); vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildHub(), { runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/', beforeParse(w){ w.scrollTo=function(){}; } });
await wait(60);
return { dom, errors, doc: dom.window.document };
}
test('hub: финал курса — 10 боссов рендерятся при раскрытии, босс решается', async () => {
const { doc, errors } = await loadHub();
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('.ch-grid .ch-card').length, 7, '7 карточек глав');
// раскрыть финал
doc.getElementById('final-head').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true }));
await wait(40);
assert.equal(doc.querySelectorAll('#fin-bosses-container .boss-card').length, 10, '10 боссов');
// решить босс 1 (Mr Ca(OH)2 = 74)
const inp = doc.getElementById('fb-1-inp'), go = doc.getElementById('fb-1-go');
inp.value = '74'; go.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true }));
assert.ok(doc.getElementById('fb-1-card').classList.contains('solved'), 'босс 1 повержен');
});
/* ── Глава 6 ── */
test('ch6: SPA без ошибок, 8 карточек, §46 активен, w/c калькуляторы', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch6.html', '/js/chem8_ch6_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 8, '7 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p46', '§46 активен');
await wait(120);
assert.ok(doc.querySelector('#c-mix .cls-chip'), 'классификатор смесей §46');
doc.defaultView.goTo('p50'); await wait(120);
assert.ok(doc.querySelector('#c-wcalc #w-go'), 'калькулятор w §50');
doc.defaultView.goTo('p51'); await wait(120);
assert.ok(doc.querySelector('#c-ccalc #c-go'), 'калькулятор c §51');
});
test('ch6: анимация растворения §47 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch6.html', '/js/chem8_ch6_widgets.js');
doc.defaultView.goTo('p47'); await wait(120);
assert.ok(doc.querySelector('#c-dissoc .ds-svg'), 'анимация диссоциации §47');
});
+230
View File
@@ -0,0 +1,230 @@
'use strict';
/*
* Phase 0 тесты учебника «Химия 8» (hub + 7 глав).
* 1. Чистые примитивы frontend/js/chem8_svg.js (window.Chem8): formula/ionLabel/chemEq.
* 2. Целостность каркаса: хаб + 7 файлов глав существуют, slug'и согласованы,
* сумма параграфов = 52, миграция 041 содержит родителя + 7 детей.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const TB = path.join(ROOT, 'frontend', 'textbooks');
// --- shim browser global, load the frontend module ---
global.window = global;
require(path.join(ROOT, 'frontend', 'js', 'chem8_svg.js'));
const C = global.Chem8;
test('Chem8.formula — числовые индексы в подстрочные', () => {
assert.equal(C.formula('CaCO3'), 'CaCO₃');
assert.equal(C.formula('H2O'), 'H₂O');
assert.equal(C.formula('Al2(SO4)3'), 'Al₂(SO₄)₃');
assert.equal(C.formula('NaCl'), 'NaCl');
});
test('Chem8.ionLabel — заряд ионов надстрочным', () => {
assert.equal(C.ionLabel('Na', 1), 'Na⁺');
assert.equal(C.ionLabel('Ca', 2), 'Ca²⁺');
assert.equal(C.ionLabel('Cl', -1), 'Cl⁻');
assert.equal(C.ionLabel('SO4', -2), 'SO₄²⁻');
assert.equal(C.ionLabel('Fe', 3), 'Fe³⁺');
assert.equal(C.ionLabel('Na', 0), 'Na');
});
test('Chem8.chemEq — стрелка, индексы, газ', () => {
const html = C.chemEq('2Na + 2H2O -> 2NaOH + H2^');
assert.ok(html.includes('2H₂O'), 'индексы воды');
assert.ok(html.includes('→'), 'стрелка реакции');
assert.ok(html.includes('H₂↑'), 'значок газа');
assert.ok(html.includes('class="ceq"'), 'обёртка');
});
test('Chem8.chemEq — обратимая реакция и осадок', () => {
const rev = C.chemEq('N2 + 3H2 <-> 2NH3');
assert.ok(rev.includes('⇌'), 'обратимая стрелка');
const prec = C.chemEq('AgNO3 + NaCl -> AgClv + NaNO3');
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 ['redoxBalancer', 'orbitalDiagram']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
assert.equal(C[fn]({}), null, fn + ' заглушка возвращает null');
}
});
test('Chem8 — Phase 2 виджеты экспортированы как функции', () => {
for (const fn of ['testTube', 'indicatorScale', 'classifier', 'solubilityTable', 'activitySeries']) {
assert.equal(typeof C[fn], 'function', fn + ' реализован');
}
assert.ok(C.testTube({ precipitate: '#88c' }).includes('<svg'), 'testTube → SVG');
});
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 },
{ slug: 'chemistry-8-ch1', file: 'chemistry_8_ch1.html', paras: 14 },
{ slug: 'chemistry-8-ch2', file: 'chemistry_8_ch2.html', paras: 5 },
{ slug: 'chemistry-8-ch3', file: 'chemistry_8_ch3.html', paras: 7 },
{ slug: 'chemistry-8-ch4', file: 'chemistry_8_ch4.html', paras: 6 },
{ slug: 'chemistry-8-ch5', file: 'chemistry_8_ch5.html', paras: 4 },
{ slug: 'chemistry-8-ch6', file: 'chemistry_8_ch6.html', paras: 7 }
];
test('сумма параграфов глав = 52', () => {
assert.equal(CHILDREN.reduce((a, c) => a + c.paras, 0), 52);
});
test('хаб chemistry_8_hub.html существует и ссылается на все 7 глав', () => {
const hub = fs.readFileSync(path.join(TB, 'chemistry_8_hub.html'), 'utf8');
assert.ok(hub.includes('var TOTAL = 52'), 'TOTAL=52');
for (const ch of CHILDREN) {
assert.ok(hub.includes('/textbook/' + ch.slug), 'ссылка на ' + ch.slug);
}
assert.ok(hub.includes('/api/textbooks/chemistry-8/children'), 'грузит детей');
});
test('каждая глава существует, ссылается на хаб и подключает chem8', () => {
for (const ch of CHILDREN) {
const html = fs.readFileSync(path.join(TB, ch.file), 'utf8');
assert.ok(html.includes('/textbook/chemistry-8"'), ch.file + ' ссылка назад в хаб');
assert.ok(html.includes('/js/chem8_svg.js'), ch.file + ' подключает chem8_svg');
// все 8 страниц (intro + 6 глав) перестроены на движок chem8_engine.js (SPA)
assert.ok(html.includes("slug:'" + ch.slug + "'"), ch.file + ' slug в CHEM8_CFG');
assert.ok(html.includes('/js/chem8_engine.js'), ch.file + ' подключает движок');
}
});
test('Phase 1 — раздел intro перестроен на движок (SPA, эталон)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_intro.html'), 'utf8');
assert.ok(html.includes('id="psel-grid"'), 'para-selector');
for (let i = 1; i <= 9; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="sec-pr1"'), 'ПР1 секция');
assert.ok(html.includes('id="sec-final1"'), 'финал-секция');
assert.ok(html.includes('window.POOLS'), 'тренажёр задач (POOLS)');
assert.ok(html.includes('window.BUILDERS'), 'builders §');
assert.ok(html.includes('function build_p6'), 'build_p6 (треугольник)');
assert.ok(html.includes('/css/chem8-textbook.css'), 'фреймворк-CSS');
assert.ok(html.includes('/js/chem8_intro_widgets.js'), 'виджеты раздела');
assert.ok(!html.includes('Раздел в разработке'), 'баннер-заглушка убран');
});
test('Phase 2 — Глава 1 построена на движке (§10–23 + лаб/ПР + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch1.html'), 'utf8');
assert.ok(html.includes('id="psel-grid"'), 'para-selector');
for (let i = 10; i <= 23; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="sec-final1"'), 'финал');
assert.ok(html.includes('id="c-ox-cls"'), 'классификатор оксидов');
assert.ok(html.includes('id="c-salt-sol"'), 'таблица растворимости');
assert.ok(html.includes('Лабораторный опыт 1'), 'Лаб.1');
assert.ok(html.includes('Практическая работа 2'), 'ПР2');
assert.ok(html.includes('/js/chem8_ch1_widgets.js'), 'виджеты главы');
assert.ok(!html.includes('Раздел в разработке'), 'заглушка убрана');
});
test('Phase 3 — Глава 2 построена на движке (§24–28 + Лаб.3 + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch2.html'), 'utf8');
for (let i = 24; i <= 28; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-pt-metals"'), 'ПСХЭ §24');
assert.ok(html.includes('id="c-amph"'), 'амфотерность §25');
assert.ok(html.includes('Лабораторный опыт 3'), 'Лаб.3');
assert.ok(html.includes('/js/chem8_ch2_widgets.js'), 'виджеты главы 2');
});
test('Chem8.miniPeriodic возвращает API с highlight', () => {
assert.equal(typeof C.miniPeriodic, 'function', 'miniPeriodic реализован');
});
test('Phase 4 — Глава 3 построена + atomShell/shellConfig корректны', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch3.html'), 'utf8');
for (let i = 29; i <= 35; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-atom"'), 'модель атома §29');
assert.ok(html.includes('id="c-passport"'), 'паспорт §35');
assert.ok(html.includes('/js/chem8_ch3_widgets.js'), 'виджеты главы 3');
assert.deepEqual(C.shellConfig(11), [2, 8, 1], 'Na: 2,8,1');
assert.deepEqual(C.shellConfig(20), [2, 8, 8, 2], 'Ca: 2,8,8,2');
assert.equal(C.nuclide(11, 23).N, 12, '²³Na: 12 нейтронов');
assert.equal(C.zSym(17), 'Cl', 'Z=17 → Cl');
});
test('Phase 5 — Глава 4 построена + bondType корректен', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch4.html'), 'utf8');
for (let i = 36; i <= 41; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-bond1"'), 'тип связи §37');
assert.ok(html.includes('Лабораторный опыт 4'), 'Лаб.4');
assert.ok(html.includes('/js/chem8_ch4_widgets.js'), 'виджеты главы 4');
assert.equal(C.bondClass('H', 'H').type, 'ковалентная неполярная');
assert.equal(C.bondClass('H', 'Cl').type, 'ковалентная полярная');
assert.equal(C.bondClass('Na', 'Cl').type, 'ионная');
});
test('Phase 6 — Глава 5 построена + oxStates корректен', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch5.html'), 'utf8');
for (let i = 42; i <= 45; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-ox"'), 'калькулятор с.о. §42');
assert.ok(html.includes('id="c-redox-pick"'), 'электронный баланс §44');
assert.ok(html.includes('/js/chem8_ch5_widgets.js'), 'виджеты главы 5');
assert.equal(C.oxStates('H2SO4').S, 6, 'S в H₂SO₄ = +6');
assert.equal(C.oxStates('KMnO4').Mn, 7, 'Mn в KMnO₄ = +7');
assert.equal(C.oxStates('HNO3').N, 5, 'N в HNO₃ = +5');
});
test('Phase 6 — Глава 6 построена (§46–52 + ПР4 + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch6.html'), 'utf8');
for (let i = 46; i <= 52; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-mix"'), 'классификатор смесей §46');
assert.ok(html.includes('id="c-wcalc"'), 'калькулятор w §50');
assert.ok(html.includes('id="c-ccalc"'), 'калькулятор c §51');
assert.ok(html.includes('Практическая работа 4'), 'ПР4');
assert.ok(html.includes('/js/chem8_ch6_widgets.js'), 'виджеты главы 6');
});
test('chem8_engine.js и виджеты — валидный синтаксис', () => {
const eng = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'chem8_engine.js'), 'utf8');
const wid = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'chem8_intro_widgets.js'), 'utf8');
assert.doesNotThrow(() => new Function(eng), 'движок парсится');
assert.doesNotThrow(() => new Function(wid), 'виджеты парсятся');
});
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');
assert.ok(/'chemistry-8'.*NULL/s.test(sql) || sql.includes("'chemistry-8', 'chemistry', 8"), 'родитель');
for (const ch of CHILDREN) {
assert.ok(sql.includes("'" + ch.slug + "'"), 'дитя ' + ch.slug);
}
// запрет эмоджи (правило проекта)
assert.ok(!/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(sql), 'без эмоджи');
});
+193
View File
@@ -0,0 +1,193 @@
'use strict';
/**
* Integration tests: /api/lab — curriculum links (Phase 5).
* Covers: related (auth), reverse lookup, admin add/delete, validation,
* role-gating, textbook/topic existence checks, enabled-filtering of reverse.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app.
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
/**
* Schema-robust insert: fills every NOT NULL column (without a default) that the
* caller didn't provide with a safe placeholder, then inserts. Returns lastInsertRowid.
* Protects the seed from schema drift (e.g. textbooks.html_path NOT NULL) introduced
* by parallel sessions on this branch.
*/
function seedRow(table, provided) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
const colNames = new Set(cols.map(c => c.name));
// Keep ONLY keys that are real columns (drops fields absent in this schema —
// robust to drift, e.g. topics may lack slug/subject_id on some branches).
const row = {};
for (const k of Object.keys(provided)) if (colNames.has(k)) row[k] = provided[k];
// Fill any required (NOT NULL, no default) column the caller didn't provide.
for (const c of cols) {
if (c.pk) continue;
if (c.name in row) continue;
if (c.notnull && c.dflt_value === null) {
row[c.name] = /INT|REAL|NUM/i.test(c.type) ? 0 : '';
}
}
const names = Object.keys(row);
const ph = names.map(() => '?').join(', ');
const info = db.prepare(`INSERT INTO ${table} (${names.join(', ')}) VALUES (${ph})`)
.run(...names.map(n => row[n]));
return info.lastInsertRowid;
}
describe('/api/lab curriculum links', () => {
let adminToken, studentToken, tbSlug, topicId;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
// Seed a textbook + topic to link against (schema-robust — fills NOT NULL cols).
tbSlug = 'phys-test';
seedRow('textbooks', { slug: tbSlug, title: 'Физика тест', subject: 'physics', grade: 9, is_active: 1 });
const subjId = seedRow('subjects', { name: 'LinkTest Subj', slug: 'linktest-subj' });
topicId = seedRow('topics', { subject_id: subjId, name: 'Колебания тест', slug: 'kolebaniya-test' });
});
it('GET /related requires auth (401)', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /related returns empty link buckets for a sim with no links', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.id, 'pendulum');
assert.deepEqual(res.body.links.textbook, []);
assert.deepEqual(res.body.links.topic, []);
});
it('POST /links is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can add a textbook link; label resolved from textbooks', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.link.kind, 'textbook');
assert.equal(res.body.link.ref_id, tbSlug);
assert.equal(res.body.link.label, 'Физика тест', 'label resolved from textbook title');
assert.ok(res.body.link.href.includes(tbSlug), 'href points to textbook');
});
it('related now shows the textbook link', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
assert.equal(res.status, 200);
assert.equal(res.body.links.textbook.length, 1);
assert.equal(res.body.links.textbook[0].ref_id, tbSlug);
});
it('admin can add a topic link; label resolved from topics', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'topic', ref_id: String(topicId) }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.link.label, 'Колебания тест');
});
it('duplicate link → 409', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 409, `got ${res.status}`);
});
it('validation: bad kind → 400', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'nope', ref_id: 'x' }, adminToken);
assert.equal(res.status, 400);
});
it('validation: missing ref_id → 400', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook' }, adminToken);
assert.equal(res.status, 400);
});
it('validation: unknown textbook → 404', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: 'ghost-book' }, adminToken);
assert.equal(res.status, 404);
});
it('validation: unknown topic → 404', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'topic', ref_id: '999999' }, adminToken);
assert.equal(res.status, 404);
});
it('POST link to unknown sim → 404', async () => {
const res = await inject('POST', '/api/lab/sims/ghostsim/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 404);
});
it('reverse lookup: GET /links?kind=textbook&ref_id= returns linked enabled sims', async () => {
const res = await inject('GET',
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(res.body.sims.some(s => s.id === 'pendulum'), 'pendulum in reverse lookup');
});
it('reverse lookup excludes disabled sims', async () => {
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: false }, adminToken);
const res = await inject('GET',
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
assert.ok(!res.body.sims.some(s => s.id === 'pendulum'), 'disabled pendulum excluded');
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore
});
it('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(res.body.byRef, 'byRef present');
assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`);
assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug');
});
it('batch reverse lookup: bad kind → 400', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken);
assert.equal(res.status, 400);
});
it('batch reverse lookup requires auth (401)', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null);
assert.equal(res.status, 401);
});
it('reverse lookup: bad kind → 400', async () => {
const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken);
assert.equal(res.status, 400);
});
it('admin can delete a link; related reflects removal', async () => {
// find the textbook link id
const rel = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
const linkId = rel.body.links.textbook[0].id;
const del = await inject('DELETE', `/api/lab/sims/pendulum/links/${linkId}`, null, adminToken);
assert.equal(del.status, 200, `got ${del.status}`);
const rel2 = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
assert.equal(rel2.body.links.textbook.length, 0, 'textbook link gone');
});
it('delete unknown link → 404', async () => {
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/999999', null, adminToken);
assert.equal(res.status, 404);
});
it('delete is admin-only (student → 403)', async () => {
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/1', null, studentToken);
assert.equal(res.status, 403);
});
});
+122
View File
@@ -0,0 +1,122 @@
'use strict';
/**
* Integration tests: /api/lab/sims — catalog from DB + admin overrides.
* Covers: seeded catalog, auth, role-gating, enabled toggle (+legacy mirror),
* featured/tags/subject/grade patch, reorder, validation.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app (setup builds its own app without it).
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
describe('/api/lab/sims', () => {
let adminToken, studentToken;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
});
it('GET /api/lab/sims requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/lab/sims', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.module_disabled, false);
assert.ok(Array.isArray(res.body.sims), 'sims is array');
assert.equal(res.body.sims.length, 40, `expected 40 sims, got ${res.body.sims.length}`);
const pend = res.body.sims.find(s => s.id === 'pendulum');
assert.ok(pend, 'pendulum present');
assert.equal(pend.cat, 'phys');
assert.equal(pend.enabled, true);
assert.deepEqual(pend.tags, []);
});
it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.body.sims[0].id, 'graph');
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
});
it('PATCH /api/lab/sims/:id is admin-only (student → 403)', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum', { featured: true }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can disable a sim; it reflects in GET and in legacy sim_disabled_ids', async () => {
const res = await inject('PATCH', '/api/lab/sims/waves', { enabled: false }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.enabled, false);
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const waves = get.body.sims.find(s => s.id === 'waves');
assert.equal(waves.enabled, false, 'waves disabled in catalog');
const legacy = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(legacy.includes('waves'), 'waves in legacy sim_disabled_ids');
await inject('PATCH', '/api/lab/sims/waves', { enabled: true }, adminToken);
const legacy2 = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(!legacy2.includes('waves'), 'waves removed from legacy after enable');
});
it('admin can set featured, tags, subject, grade', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum',
{ featured: true, tags: ['колебания', 'механика'], subject: 'physics', grade: 9 }, adminToken);
assert.equal(res.status, 200);
assert.equal(res.body.sim.featured, true);
assert.deepEqual(res.body.sim.tags, ['колебания', 'механика']);
assert.equal(res.body.sim.subject, 'physics');
assert.equal(res.body.sim.grade, 9);
});
it('PATCH rejects bad grade and bad category and non-array tags', async () => {
const g = await inject('PATCH', '/api/lab/sims/pendulum', { grade: 99 }, adminToken);
assert.equal(g.status, 400, 'bad grade rejected');
const c = await inject('PATCH', '/api/lab/sims/pendulum', { cat: 'nope' }, adminToken);
assert.equal(c.status, 400, 'bad cat rejected');
const t = await inject('PATCH', '/api/lab/sims/pendulum', { tags: 'notarray' }, adminToken);
assert.equal(t.status, 400, 'non-array tags rejected');
});
it('PATCH unknown sim → 404', async () => {
const res = await inject('PATCH', '/api/lab/sims/nonexistent', { featured: true }, adminToken);
assert.equal(res.status, 404, `got ${res.status}`);
});
it('POST /api/lab/sims/reorder updates sort order (admin)', async () => {
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const ids = get.body.sims.map(s => s.id);
const reordered = ['angrybirds', 'graph', ...ids.filter(id => id !== 'angrybirds' && id !== 'graph')];
const res = await inject('POST', '/api/lab/sims/reorder', { order: reordered }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.count, 40);
const get2 = await inject('GET', '/api/lab/sims', null, adminToken);
assert.equal(get2.body.sims[0].id, 'angrybirds', 'angrybirds now first');
assert.equal(get2.body.sims[1].id, 'graph', 'graph now second');
});
it('reorder rejects unknown id and empty order', async () => {
const bad = await inject('POST', '/api/lab/sims/reorder', { order: ['ghost'] }, adminToken);
assert.equal(bad.status, 400, 'unknown id rejected');
const empty = await inject('POST', '/api/lab/sims/reorder', { order: [] }, adminToken);
assert.equal(empty.status, 400, 'empty order rejected');
});
it('reorder is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/reorder', { order: ['graph'] }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
});
+428
View File
@@ -0,0 +1,428 @@
/* chem8-textbook.css — фреймворк интерактивных учебников «Химия 8».
Палитра amber; структура и классы повторяют учебники физики. */
:root{
--bg:#fffbeb; --card:#fff; --card-soft:#fef9ec; --text:#1c1917; --muted:#78716c; --border:#f0e6cf;
--pri:#d97706; --pri-d:#b45309; --pri-l:#fbbf24; --pri-soft:#fef3c7;
--sec-acc:#d97706; --sec-acc-d:#b45309; --sec-acc-soft:#fef3c7;
--ok:#15803d; --ok-bg:#dcfce7; --fail:#b91c1c; --fail-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; --card-soft:#2e2118; --text:#fef3c7; --muted:#c9ab82; --border:#4a3520;
--pri-soft:rgba(217,119,6,.18); --sec-acc-soft:rgba(217,119,6,.18);
--ok-bg:rgba(21,128,61,.2); --fail-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.6;transition:background .25s,color .25s}
a{color:inherit;text-decoration:none}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
/* HEADER */
.hdr{position:relative;background:linear-gradient(110deg,#92400e 0%,#d97706 55%,#fbbf24 100%);color:#fff;padding:26px 24px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
.hdr::before{content:'ХИМИЯ';position:absolute;right:-10px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(3rem,11vw,8rem);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}
.hdr-row{position:relative;z-index:1;max-width:1240px;margin:0 auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:900;letter-spacing:-.01em}
.hdr-sub{font-size:.84rem;opacity:.9;margin-top:3px;max-width:640px}
.hdr-side{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.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;text-decoration:none}
.hdr-btn:hover{background:rgba(255,255,255,.26)}
/* LAYOUT */
.main{max-width:1240px;margin:0 auto;padding:22px 24px 60px;display:grid;grid-template-columns:1fr 290px;gap:26px;align-items:start}
@media(max-width:980px){.main{grid-template-columns:1fr;padding:16px}}
.col-main{min-width:0}
.col-side{position:sticky;top:14px;display:flex;flex-direction:column;gap:14px}
@media(max-width:980px){.col-side{position:static}}
/* HERO */
.hero{background:linear-gradient(135deg,var(--pri-soft),rgba(251,191,36,.1));border:1px solid var(--border);border-radius:18px;padding:22px 24px;margin-bottom:22px;position:relative;overflow:hidden}
.hero h2{font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:800;color:var(--pri-d);margin-bottom:8px}
html.dark .hero h2{color:var(--pri-l)}
.hero p{font-size:.92rem;color:var(--text);opacity:.86;max-width:640px;margin-bottom:14px}
.hero-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.btn-primary{padding:11px 20px;background:linear-gradient(135deg,var(--pri),var(--pri-l));color:#fff;border:0;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-family:inherit;box-shadow:var(--sh2)}
.btn-primary:hover{filter:brightness(1.07)}
.hero-progress{flex:1;min-width:180px}
.hp-label{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.hp-bar{height:8px;background:rgba(217,119,6,.16);border-radius:5px;overflow:hidden;margin:5px 0}
.hp-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--pri-l));width:0;transition:width .5s}
.hp-text{font-size:.8rem;font-weight:700;color:var(--pri-d)}
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;background:linear-gradient(135deg,#f59e0b,var(--pri));color:#fff;border-radius:99px;font-size:.8rem;font-weight:800;font-family:'Unbounded',sans-serif}
/* PARA-SELECTOR */
.psel{margin-bottom:24px}
.psel-title{font-family:'Outfit',sans-serif;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:10px}
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
.psel-card{position:relative;background:var(--card);border:1.5px solid var(--border);border-radius:13px;padding:13px 14px 16px;cursor:pointer;transition:transform .16s,box-shadow .16s,border-color .16s;overflow:hidden}
.psel-card:hover{transform:translateY(-3px);box-shadow:var(--sh2);border-color:var(--pri)}
.psel-card.active{border-color:var(--pri);box-shadow:0 0 0 2px var(--pri-soft)}
.psel-card.final{background:linear-gradient(135deg,var(--pri-soft),var(--card))}
.psel-num{font-family:'Outfit';font-weight:800;color:var(--pri);font-size:.84rem;margin-bottom:4px}
.psel-name{font-size:.86rem;font-weight:700;line-height:1.3}
.psel-sub{font-size:.74rem;color:var(--muted);margin-top:3px}
.psel-prog{height:5px;background:rgba(0,0,0,.07);border-radius:3px;overflow:hidden;margin-top:9px}
.psel-prog-fill{height:100%;width:0;background:linear-gradient(90deg,var(--pri),var(--pri-l));transition:width .5s}
.psel-done{position:absolute;top:9px;right:9px;width:20px;height:20px;border-radius:50%;background:var(--ok);display:none;align-items:center;justify-content:center}
.psel-done svg{width:12px;height:12px;stroke:#fff;fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.psel-card.done .psel-done{display:flex}
/* SECTIONS */
.sec{display:none}
.sec.active{display:block;animation:fadeIn .25s}
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.sec-header{display:flex;align-items:center;gap:12px;margin-bottom:16px}
.sec-num{background:linear-gradient(135deg,var(--pri),var(--pri-l));color:#fff;font-family:'Outfit';font-weight:800;font-size:.9rem;padding:6px 13px;border-radius:10px;flex-shrink:0}
.sec-h{font-family:'Outfit',sans-serif;font-size:1.25rem;font-weight:800;line-height:1.25}
/* PARA-HERO */
.para-hero{border-radius:16px;padding:20px 22px;color:#fff;position:relative;overflow:hidden;margin-bottom:18px}
.para-hero::after{content:'';position:absolute;right:-28px;top:-28px;width:140px;height:140px;border-radius:50%;opacity:.14;background:#fff}
.ph-label{font-size:.7rem;font-weight:800;letter-spacing:.08em;text-transform:uppercase;opacity:.8;margin-bottom:5px;position:relative;z-index:1}
.para-hero h2{font-family:'Outfit',sans-serif;font-size:1.25rem;font-weight:800;margin-bottom:9px;line-height:1.25;position:relative;z-index:1}
.ph-formula{display:inline-block;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.25);border-radius:10px;padding:6px 15px;font-weight:700;margin-bottom:10px;position:relative;z-index:1}
.ph-desc{font-size:.88rem;opacity:.92;line-height:1.6;margin-bottom:11px;max-width:680px;position:relative;z-index:1}
.ph-tags{display:flex;flex-wrap:wrap;gap:6px;position:relative;z-index:1}
.ph-tag{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.25);border-radius:20px;padding:3px 11px;font-size:.72rem;font-weight:700}
.ph-1{background:linear-gradient(135deg,#92400e,#d97706 55%,#fbbf24)}
.ph-2{background:linear-gradient(135deg,#134e4a,#0d9488 55%,#2dd4bf)}
.ph-3{background:linear-gradient(135deg,#3730a3,#4f46e5 55%,#818cf8)}
.ph-4{background:linear-gradient(135deg,#1e3a8a,#2563eb 55%,#60a5fa)}
.ph-5{background:linear-gradient(135deg,#064e3b,#059669 55%,#34d399)}
.ph-6{background:linear-gradient(135deg,#7c2d12,#ea580c 55%,#fb923c)}
.ph-7{background:linear-gradient(135deg,#164e63,#0891b2 55%,#22d3ee)}
.ph-8{background:linear-gradient(135deg,#581c87,#9333ea 55%,#c084fc)}
.ph-9{background:linear-gradient(135deg,#831843,#db2777 55%,#f472b6)}
.ph-pr{background:linear-gradient(135deg,#7c2d12,#c2410c 55%,#fb923c)}
.ph-final{background:linear-gradient(135deg,#92400e,#d97706 55%,#f59e0b)}
/* CARDS */
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:0;box-shadow:var(--sh);margin-bottom:14px;overflow:hidden}
.card-header{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--border);background:var(--card-soft)}
.card-icon{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff}
.card-icon.theory{background:linear-gradient(135deg,#2563eb,#60a5fa)}
.card-icon.example{background:linear-gradient(135deg,#059669,#34d399)}
.card-icon.rule{background:linear-gradient(135deg,#d97706,#fbbf24)}
.card-icon.lab{background:linear-gradient(135deg,#db2777,#f472b6)}
.card-icon .ic{width:17px;height:17px;stroke:#fff}
.card-title{font-family:'Outfit',sans-serif;font-weight:800;font-size:.96rem;flex:1}
.card-num{font-family:'Outfit';font-weight:800;color:var(--muted);font-size:.82rem}
.card-body{padding:15px 17px;font-size:.93rem}
.card-body p{margin-bottom:9px}.card-body p:last-child{margin-bottom:0}
.card-body ul,.card-body ol{margin:6px 0 9px 20px}
.card-body li{margin-bottom:4px}
.card-body b{color:var(--pri-d)}
html.dark .card-body b{color:var(--pri-l)}
.section-title{font-family:'Outfit';font-weight:800;font-size:1rem;margin:14px 0 10px;color:var(--pri-d)}
html.dark .section-title{color:var(--pri-l)}
.formula-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:10px 0}
.fcard{background:var(--card-soft);border:1.5px solid var(--border);border-radius:12px;padding:13px 15px}
.fcard.highlight{border-color:var(--pri);background:var(--pri-soft)}
.fcard h3{font-family:'Outfit';font-size:.9rem;font-weight:800;margin-bottom:6px}
.main-f{font-size:1.05rem;font-weight:700;color:var(--pri-d);font-family:var(--mono)}
html.dark .main-f{color:var(--pri-l)}
.def-box{background:var(--pri-soft);border-left:4px solid var(--pri);border-radius:0 10px 10px 0;padding:12px 16px;margin:10px 0;font-size:.91rem;line-height:1.7}
.def-box b{color:var(--pri-d)}html.dark .def-box b{color:var(--pri-l)}
.remember-box{background:linear-gradient(135deg,var(--warn-bg),var(--pri-soft));border:1.5px solid var(--pri-l);border-radius:13px;padding:14px 17px;margin:14px 0}
.remember-box-title{font-weight:800;font-size:.86rem;color:#92400e;margin-bottom:8px;display:flex;align-items:center;gap:7px}
html.dark .remember-box-title{color:#fde68a}
.remember-box ul{margin:0 0 0 18px;font-size:.88rem}
.remember-box li{margin-bottom:5px}
.insight-box{background:linear-gradient(135deg,rgba(79,70,229,.07),rgba(139,92,246,.04));border:2px solid rgba(79,70,229,.2);border-radius:13px;padding:13px 16px;margin:14px 0}
.insight-title{font-weight:800;font-size:.82rem;color:#4f46e5;margin-bottom:7px;display:flex;align-items:center;gap:7px}
html.dark .insight-title{color:#a5b4fc}
.insight-box p{font-size:.85rem;line-height:1.75;margin-bottom:5px}
.note-safe{display:flex;gap:9px;background:var(--warn-bg);border:1px solid var(--pri-l);border-radius:10px;padding:10px 13px;font-size:.86rem;margin:10px 0}
.note-safe svg{stroke:var(--pri-d);margin-top:2px;width:18px;height:18px;fill:none;stroke-width:2}
/* life-grid */
.life-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:14px 0}
.life-item{background:var(--card);border:1.5px solid var(--border);border-radius:12px;padding:13px 11px;text-align:center}
.li-icon{display:flex;justify-content:center;margin-bottom:7px}
.li-icon svg{width:26px;height:26px;stroke:var(--pri);fill:none;stroke-width:1.8}
.li-title{font-size:.82rem;font-weight:800;margin-bottom:3px}
.li-desc{font-size:.74rem;color:var(--muted);line-height:1.5}
/* q-list */
.q-list{margin:8px 0 0 20px;font-size:.9rem}
.q-list li{margin-bottom:7px;line-height:1.6}
/* TASKS */
.legacy-tasks{margin-top:20px;padding:16px 18px;background:var(--card);border:1.5px solid var(--border);border-radius:14px}
.lt-head{display:flex;gap:10px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
.lt-title{font-weight:800;font-family:'Outfit'}
.chip{padding:3px 11px;border-radius:99px;font-weight:700;font-size:.8rem}
.chip-ok{margin-left:auto;background:var(--ok-bg);color:var(--ok)}
.chip-tot{background:rgba(120,80,10,.08);color:var(--muted)}
.lt-reset{padding:5px 11px;font-size:.78rem}
.prog-wrap{height:5px;background:rgba(0,0,0,.07);border-radius:3px;overflow:hidden;margin-bottom:10px}
.prog-fill{height:100%;width:0;background:linear-gradient(90deg,var(--pri),var(--pri-l));transition:width .4s}
.nav-dots{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}
.nav-dot{min-width:30px;height:30px;padding:0 6px;border-radius:8px;border:2px solid var(--border);background:var(--card);font-size:.74rem;font-weight:700;cursor:pointer;display:grid;place-items:center;color:var(--muted);font-family:var(--mono);transition:.15s}
.nav-dot:hover{border-color:var(--pri);color:var(--pri)}
.nav-dot.nd-cur{background:var(--pri);border-color:var(--pri);color:#fff}
.nav-dot.nd-ok{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
.nav-dot.nd-fail{background:var(--fail-bg);border-color:var(--fail);color:var(--fail)}
.task-card{background:var(--card-soft);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
.task-num{font-size:.74rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
.task-text{font-size:.94rem;line-height:1.65;margin-bottom:11px}
.task-hint{display:flex;gap:7px;align-items:flex-start;background:var(--warn-bg);border-radius:9px;padding:8px 12px;font-size:.84rem;margin-bottom:11px;color:var(--text)}
.task-hint svg{stroke:var(--pri-d);width:15px;height:15px;flex-shrink:0;margin-top:2px}
.ans-row{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
.ans-row label{font-weight:700;font-size:.88rem}
.ans-inp{padding:8px 12px;border:1.5px solid var(--border);border-radius:9px;background:var(--card);color:var(--text);font-family:var(--mono);width:120px;font-size:.95rem}
.ans-inp:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.unit-lbl{font-size:.86rem;color:var(--muted);font-weight:600}
.mcq-opts{display:flex;flex-direction:column;gap:8px}
.mcq-opt{width:100%;text-align:left;padding:11px 15px;border:2px solid var(--border);border-radius:10px;background:var(--card);color:var(--text);font-size:.9rem;cursor:pointer;transition:.16s;line-height:1.5;font-family:inherit}
.mcq-opt:hover:not(:disabled){border-color:var(--pri);background:var(--pri-soft)}
.mcq-let{font-weight:800;margin-right:6px;color:var(--pri)}
.mcq-opt.mcq-cor{border-color:var(--ok)!important;background:var(--ok-bg)!important;color:var(--ok)!important;font-weight:700}
.mcq-opt.mcq-wrong{border-color:var(--fail)!important;background:var(--fail-bg)!important;color:var(--fail)!important}
.feedback{display:none;padding:11px 14px;border-radius:10px;font-size:.89rem;margin-top:10px;line-height:1.55}
.feedback.show{display:block}
.feedback.fb-ok{background:var(--ok-bg);color:var(--ok);border-left:4px solid var(--ok)}
.feedback.fb-fail{background:var(--fail-bg);color:var(--fail);border-left:4px solid var(--fail)}
.feedback b{font-weight:800}
.lt-foot{display:flex;justify-content:flex-end;margin-top:10px}
.summary{display:none;text-align:center;padding:16px;margin-top:12px;background:linear-gradient(135deg,var(--pri-soft),var(--card));border-radius:12px}
.summary.show{display:block}
.sum-t{font-weight:800;margin-bottom:5px;font-family:'Outfit'}
.big-score{font-size:1.6rem;font-weight:900;color:var(--pri-d)}
html.dark .big-score{color:var(--pri-l)}
.sum-grade{margin-top:5px;color:var(--muted);font-size:.88rem}
/* BUTTONS */
.btn{font-family:inherit;font-weight:700;font-size:.88rem;padding:8px 15px;border-radius:9px;border:1.5px solid var(--border);background:var(--card);color:var(--text);cursor:pointer;transition:.15s;display:inline-flex;align-items:center;gap:7px}
.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.08)}
.sec-nav{display:flex;justify-content:space-between;gap:12px;margin-top:20px}
.read-wrap{margin-top:18px;display:flex;justify-content:center}
/* SIDEBAR cards */
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:13px;padding:14px 16px;box-shadow:var(--sh)}
.sidecard h4{font-family:'Outfit';font-size:.86rem;font-weight:800;margin-bottom:9px;display:flex;align-items:center;gap:6px}
.sidecard h4 svg{width:14px;height:14px}
.sidecard-row{font-size:.85rem;padding:5px 0;border-bottom:1px dashed var(--border);line-height:1.5}
.sidecard-row:last-child{border-bottom:0}
.sidecard-row b{color:var(--pri-d);font-weight:700}
html.dark .sidecard-row b{color:var(--pri-l)}
.sidecard-row.done{color:var(--ok);border-bottom:0;padding:3px 0}
.sidecard.tip{background:linear-gradient(135deg,var(--warn-bg),var(--pri-soft));border-color:var(--pri-l)}
.sidecard.tip h4{color:#92400e}html.dark .sidecard.tip h4{color:#fde68a}
.xp-card{background:linear-gradient(135deg,var(--pri),var(--pri-d));color:#fff;border-radius:13px;padding:14px 16px;box-shadow:var(--sh)}
.xp-card-title{display:flex;justify-content:space-between;font-size:.78rem;font-weight:700;margin-bottom:8px}
.xp-level{background:rgba(255,255,255,.22);padding:2px 9px;border-radius:99px;font-weight:800}
.xp-bar{height:7px;background:rgba(255,255,255,.25);border-radius:4px;overflow:hidden}
.xp-fill{height:100%;background:#fff;transition:width .5s}
.xp-nums{display:flex;justify-content:space-between;font-size:.72rem;margin-top:5px;opacity:.9}
/* FLAGSHIP */
.flag-card{position:relative;background:linear-gradient(135deg,var(--card),var(--pri-soft));border:2px solid var(--pri);border-radius:16px;padding:18px 20px;margin:16px 0}
.flag-card::before{content:'★ ФЛАГМАН';position:absolute;top:12px;right:14px;background:linear-gradient(135deg,#fbbf24,#f59e0b);color:#fff;padding:4px 11px;border-radius:99px;font-weight:800;font-size:.66rem;letter-spacing:.03em}
.flag-title{font-family:'Outfit';font-weight:800;font-size:1.02rem;color:var(--pri-d);margin-bottom:4px;padding-right:90px}
html.dark .flag-title{color:var(--pri-l)}
.flag-help{font-size:.84rem;color:var(--muted);margin-bottom:12px}
/* WIDGET shell (общий для виджетов §) */
.wgt{background:var(--card);border:1.5px solid var(--pri-soft);border-radius:14px;padding:16px 18px;box-shadow:var(--sh);margin:14px 0}
.wgt-h{font-family:'Outfit';font-size:.94rem;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);width:18px;height:18px;fill:none;stroke-width:2}
.fld{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
.fld label{font-size:.85rem;font-weight:600;color:var(--muted)}
.wgt input[type=text],.wgt input[type=number],.wgt select{font-family:inherit;font-size:.94rem;padding:8px 11px;border:1.5px solid var(--border);border-radius:9px;background:var(--card);color:var(--text)}
.wgt input:focus,.wgt select:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.out{margin-top:10px;padding:11px 14px;border-radius:10px;font-size:.92rem;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(--fail-bg);border-color:#fca5a5;color:var(--fail)}
.bd{font-family:var(--mono);font-size:.88rem;line-height:1.75}
/* 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%;padding:8px 11px;border:1.5px solid var(--border);border-radius:9px;background:var(--card);color:var(--text);font-family:var(--mono)}
.mtri-f input:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.mtri-out{grid-column:1/-1;padding:10px 13px;border-radius:10px;background:var(--card-soft);border:1px solid var(--border);font-size:.9rem}
.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:.83rem;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;border:1.5px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-family:var(--mono)}
.ceqb-coef:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.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(--fail)}
.ceqb-tab{border-collapse:collapse;font-size:.84rem;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(--fail-bg);color:var(--fail)}
.ceqb-tab tr.eq td{background:var(--ok-bg);color:var(--ok)}
.ceqb-btn{font-family:inherit;font-weight:700;font-size:.86rem;padding:7px 14px;border-radius:9px;border:1.5px solid var(--border);background:var(--card);color:var(--text);cursor:pointer}
.ceqb-btn.primary{background:linear-gradient(135deg,var(--pri),var(--pri-l));color:#fff;border-color:transparent}
/* element grid */
.el-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,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:.58rem;color:var(--muted)}
.el-cell .s{font-size:1.02rem;font-weight:800;color:var(--pri-d)}
html.dark .el-cell .s{color:var(--pri-l)}
.el-cell .a{font-size:.54rem;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:.92rem;min-height:46px}
/* DnD */
.dnd-pool{display:flex;flex-wrap:wrap;gap:8px;border:1.5px dashed var(--border);border-radius:10px;padding:10px;min-height:48px;margin-bottom:10px}
.dnd-chip{padding:7px 13px;border:1.5px solid var(--border);border-radius:10px;cursor:grab;background:var(--card);font-size:.86rem;font-weight:600;user-select:none}
.dnd-chip.placed{background:var(--pri-soft);border-color:var(--pri)}
.dnd-zones{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.drop-box{border:1.5px dashed var(--border);border-radius:10px;padding:10px;min-height:80px;background:var(--card-soft)}
.drop-box.over{border-color:var(--pri);background:var(--pri-soft);border-style:solid}
.drop-box h5{font-size:.8rem;font-weight:800;margin-bottom:8px;text-align:center;color:var(--pri-d)}
html.dark .drop-box h5{color:var(--pri-l)}
/* testTube */
.tt-svg{color:var(--pri);vertical-align:bottom}
.tt-row{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-end;margin:10px 0}
.tt-cap{font-size:.84rem;color:var(--muted);text-align:center;max-width:120px}
/* indicatorScale */
.ind-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.ind-row label{font-size:.85rem;font-weight:600;color:var(--muted)}
.ind-strip{height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:.92rem;border:1px solid var(--border);transition:background .25s}
.ind-label{margin-top:8px;font-size:.9rem}
.ind-label b{color:var(--pri-d)}html.dark .ind-label b{color:var(--pri-l)}
/* classifier */
.cls-chip.on{border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.cls-chip.cls-ok{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
.cls-chip.cls-bad{background:var(--fail-bg);border-color:var(--fail);color:var(--fail)}
.cls-items{display:flex;flex-wrap:wrap;gap:6px;min-height:24px}
/* solubilityTable */
.sol-wrap{overflow-x:auto}
.sol-tab{border-collapse:collapse;font-size:.78rem;font-family:var(--mono);min-width:520px}
.sol-tab th,.sol-tab td{border:1px solid var(--border);padding:4px 6px;text-align:center;cursor:pointer}
.sol-tab thead th{background:var(--card-soft);font-weight:800}
.sol-tab th[data-an]{background:var(--card-soft);font-weight:800}
.sol-tab td.sP{background:rgba(37,99,235,.12);color:#1d4ed8}
.sol-tab td.sM{background:rgba(245,158,11,.18);color:#b45309}
.sol-tab td.sH{background:rgba(220,38,38,.14);color:#b91c1c}
.sol-tab td.sX{background:rgba(120,120,120,.12);color:var(--muted)}
.sol-tab td.sol-dim,.sol-tab th.sol-dim{opacity:.3}
.sol-tab td.sol-hot{outline:3px solid var(--pri);outline-offset:-3px;font-weight:900}
.sol-out{margin-top:10px}
/* activitySeries */
.act-row{display:flex;flex-wrap:wrap;gap:4px;align-items:center}
.act-cell{font-family:var(--mono);font-weight:800;font-size:.82rem;padding:7px 9px;border:1.5px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);cursor:pointer;transition:.12s}
.act-cell:hover{border-color:var(--pri)}
.act-cell.act-h{background:var(--card-soft);color:var(--muted);cursor:default;font-size:.74rem}
.act-cell.act-on{background:var(--pri);border-color:var(--pri);color:#fff}
.act-cell.act-disp{background:var(--ok-bg);border-color:var(--ok);color:var(--ok)}
.act-axis{display:flex;justify-content:space-between;font-size:.72rem;color:var(--muted);margin:6px 2px}
.act-out{margin-top:8px}
/* miniPeriodic */
.pt-wrap{overflow-x:auto;padding-bottom:6px}
.pt-grid{display:grid;grid-template-columns:repeat(18,minmax(30px,1fr));grid-auto-rows:34px;gap:2px;min-width:600px}
.pt-cell{position:relative;border:1px solid var(--border);border-radius:5px;background:var(--card);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1px;transition:.1s;overflow:hidden}
.pt-cell:hover{transform:scale(1.12);z-index:2;border-color:var(--pri)}
.pt-z{font-size:.5rem;color:var(--muted);line-height:1}
.pt-s{font-size:.74rem;font-weight:800;line-height:1.05}
.pt-metal{background:rgba(13,148,136,.12)}
.pt-nonmetal{background:rgba(245,158,11,.16)}
.pt-metalloid{background:rgba(124,58,237,.13)}
.pt-noble{background:rgba(37,99,235,.13)}
.pt-lanth,.pt-act{background:rgba(219,39,119,.12)}
.pt-lanth .pt-z,.pt-act .pt-z{font-size:.44rem}
.pt-cell.pt-hot{outline:2.5px solid var(--pri);outline-offset:-2px;z-index:1;box-shadow:0 0 0 3px var(--pri-soft)}
.pt-cell.pt-sel{background:var(--pri);border-color:var(--pri)}
.pt-cell.pt-sel .pt-s,.pt-cell.pt-sel .pt-z{color:#fff}
.pt-info{margin-top:10px;padding:11px 14px;border-radius:10px;background:var(--card-soft);border:1px solid var(--border);font-size:.92rem}
.pt-info b{color:var(--pri-d)}html.dark .pt-info b{color:var(--pri-l)}
.pt-legend{display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;font-size:.76rem;color:var(--muted)}
.pt-legend span{display:inline-flex;align-items:center;gap:5px}
.pt-legend i{width:12px;height:12px;border-radius:3px;display:inline-block}
.pt-modes{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
/* модель атома (§29,33) */
.as-svg{width:100%;max-width:320px;height:auto;color:var(--pri);display:block;margin:8px auto}
.as-stage{display:flex;justify-content:center}
.as-cfg{margin-top:6px}
.as-zl{font-weight:800;color:var(--pri-d)}html.dark .as-zl{color:var(--pri-l)}
/* паспорт элемента (§35) */
.passport{margin-top:10px;padding:13px 16px;border-radius:11px;background:var(--card-soft);border:1px solid var(--border)}
.passport h4{font-family:'Outfit';font-weight:800;margin-bottom:8px;color:var(--pri-d)}
html.dark .passport h4{color:var(--pri-l)}
.passport-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;font-size:.85rem}
.passport-grid div{padding:6px 9px;background:var(--card);border:1px solid var(--border);border-radius:8px}
.passport-grid b{color:var(--pri-d)}html.dark .passport-grid b{color:var(--pri-l)}
/* тип связи (§37,38) */
.bt-svg{width:100%;max-width:280px;height:auto;color:var(--text);display:block;margin:8px auto}
.bt-stage{display:flex;justify-content:center}
.bt-out.ok{background:var(--ok-bg);border-color:#86efac}
.bt-out.bad{background:var(--fail-bg);border-color:#fca5a5}
/* решётки (§41) */
.lat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin:10px 0}
.lat-card{border:1.5px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--card-soft)}
.lat-card h4{font-family:'Outfit';font-weight:800;font-size:.92rem;margin-bottom:6px;color:var(--pri-d)}
html.dark .lat-card h4{color:var(--pri-l)}
.lat-card .lat-ex{font-family:var(--mono);font-size:.82rem;color:var(--muted);margin-bottom:4px}
.lat-card ul{margin:4px 0 0 16px;font-size:.82rem}
/* орбитали (§32) — статичные SVG */
.orb-row{display:flex;gap:18px;flex-wrap:wrap;justify-content:center;margin:10px 0}
.orb-item{text-align:center}
.orb-item svg{width:90px;height:90px;color:var(--pri)}
.orb-item .orb-lab{font-size:.82rem;font-weight:700;margin-top:4px}
/* амфотерность (§25) */
.amph-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.amph-stage{display:flex;justify-content:center;margin:8px 0}
.amph-out{margin-top:6px}
/* 3D-модели молекул/решёток (§38,41) */
.mol-cv{background:#0b1220;cursor:grab;border:1px solid var(--border)}
.mol-cv:active{cursor:grabbing}
.mol-info{margin-top:8px}
/* геном-карта классов (§22) */
.gm-svg{width:100%;max-width:440px;height:auto;color:var(--text);display:block;margin:4px auto}
.gm-out{margin-top:8px}
.gm-edge{transition:stroke .15s,stroke-width .15s}
/* диссоциация/растворение (§47) */
.ds-svg{width:100%;max-width:300px;height:auto;display:block;margin:6px auto}
.ds-stage{display:flex;justify-content:center}
.ds-out{margin-top:6px}
/* exa-step (разбор примеров) */
.exa-step{font-family:var(--mono);font-size:.9rem;background:var(--card-soft);border-left:3px solid var(--pri);border-radius:0 8px 8px 0;padding:8px 12px;margin:6px 0}
/* FOOTER + popup */
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
.ach-popup{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(130px);background:var(--card);border:1.5px solid var(--pri);color:var(--text);padding:12px 20px;border-radius:13px;font-weight:700;box-shadow:var(--sh2);z-index:60;transition:transform .35s;display:flex;align-items:center;gap:10px;font-size:.9rem;max-width:90vw}
.ach-popup svg{width:20px;height:20px;stroke:var(--pri);fill:none;stroke-width:2}
.ach-popup.show{transform:translateX(-50%) translateY(0)}
.ach-popup.gold{background:linear-gradient(135deg,#fbbf24,#f59e0b);color:#fff;border-color:transparent}
.ach-popup.gold svg{stroke:#fff}
+162 -74
View File
@@ -1,90 +1,78 @@
'use strict'; 'use strict';
/* admin → sims (simulations) section */ /* admin → sims (simulations) section — контент-движок, Фазы 4-5.
*
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
* курикулумные связи (Фаза 5). Мастер-тумблер модуля — /api/settings/sims. */
(function () { (function () {
'use strict'; 'use strict';
let inited = false; let inited = false;
// Full list of available (non-null id) sims mirrored from /lab const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
const ADMIN_SIMS = [ const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
{ id: 'graph', cat: 'Математика', title: 'График функции' },
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
{ id: 'emfield', cat: 'Физика', title: 'Электромагнитные поля' },
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'opticsbench', cat: 'Физика', title: 'Оптическая скамья' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'heatengine', cat: 'Физика', title: 'Тепловые двигатели' },
{ id: 'radioactive', cat: 'Физика', title: 'Радиоактивный распад' },
{ id: 'race', cat: 'Физика', title: 'Гонка с задачами' },
{ id: 'logic', cat: 'Физика', title: 'Логические схемы' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
{ id: 'stoichiometry', cat: 'Химия', title: 'Стехиометрия' },
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
{ id: 'qualanalysis', cat: 'Химия', title: 'Качественный анализ' },
{ id: 'periodic', cat: 'Химия', title: 'Периодическая таблица' },
{ id: 'organic', cat: 'Химия', title: 'Органическая химия' },
{ id: 'solutions', cat: 'Химия', title: 'Растворы' },
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
];
let _simsSettings = { module_disabled: false, disabled_ids: [] }; let _moduleDisabled = false;
let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}]
let _textbooks = null; // кэш каталога учебников для выпадающего списка связей
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
async function load() { async function load() {
try { try {
const data = await LS.api('/api/settings/sims'); const data = await LS.api('/api/lab/sims');
_simsSettings = data; _moduleDisabled = !!data.module_disabled;
_sims = Array.isArray(data.sims) ? data.sims : [];
_render(); _render();
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка загрузки симуляций: ' + e.message, 'error'); }
} }
function _render() { function _render() {
// master toggle
const masterChk = document.getElementById('sims-master-chk'); const masterChk = document.getElementById('sims-master-chk');
if (masterChk) masterChk.checked = !_simsSettings.module_disabled; if (masterChk) masterChk.checked = !_moduleDisabled;
// per-sim cards
const grid = document.getElementById('sims-grid'); const grid = document.getElementById('sims-grid');
const dis = new Set(_simsSettings.disabled_ids || []); if (!grid) return;
// group by category
// group by category, preserving catalogue sort within group
const byCat = {}; const byCat = {};
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); }); _sims.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
const cats = CAT_ORDER.filter(c => byCat[c]).concat(
Object.keys(byCat).filter(c => !CAT_ORDER.includes(c)));
let html = ''; let html = '';
Object.entries(byCat).forEach(([cat, sims]) => { cats.forEach(cat => {
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`; html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(CAT_LABEL[cat] || cat)}</div>`;
sims.forEach(s => { byCat[cat].forEach(s => {
const enabled = !dis.has(s.id); const tags = (s.tags || []).map(t => esc(t)).join(', ');
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}"> html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}" style="flex-wrap:wrap">
<div class="perm-info"> <div class="perm-info">
<div class="perm-label">${esc(s.title)}</div> <div class="perm-label">
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div> ${esc(s.title)}
<button class="sim-star" title="${s.featured ? 'Убрать из рекомендуемых' : 'Сделать рекомендуемой'}"
onclick="simToggleFeatured('${esc(s.id)}', ${s.featured ? 'false' : 'true'})"
style="background:none;border:none;cursor:pointer;padding:0 0 0 6px;vertical-align:middle">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;${s.featured ? 'fill:var(--amber);stroke:var(--amber)' : 'fill:none;stroke:var(--text-3)'}"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</button>
</div>
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}${tags ? ' · ' + tags : ''}</div>
</div> </div>
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}"> <div style="display:flex;align-items:center;gap:8px">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" /> <button class="sim-links-btn" title="Связи с программой"
<span class="perm-track"></span> onclick="simToggleLinks('${esc(s.id)}')"
<span class="perm-thumb"></span> style="background:none;border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;cursor:pointer;padding:4px 8px;font-size:.7rem;color:var(--text-2);display:inline-flex;align-items:center;gap:4px">
</label> <svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Связи
</button>
<label class="perm-toggle" title="${s.enabled ? 'Отключить' : 'Включить'}">
<input type="checkbox" ${s.enabled ? 'checked' : ''} onchange="simToggleOne('${esc(s.id)}', this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
<div class="sim-links-panel" id="simlinks-${esc(s.id)}" style="display:none;flex-basis:100%;width:100%;margin-top:8px;padding-top:8px;border-top:1px solid var(--border,rgba(255,255,255,.1))"></div>
</div>`; </div>`;
}); });
}); });
@@ -95,26 +83,126 @@
async function simsMasterToggle(checked) { async function simsMasterToggle(checked) {
try { try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) }); await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
_simsSettings.module_disabled = !checked; _moduleDisabled = !checked;
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning'); LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
} }
async function simToggleOne(simId, enabled) { async function simToggleOne(simId, enabled) {
const dis = new Set(_simsSettings.disabled_ids || []);
if (enabled) dis.delete(simId); else dis.add(simId);
const disabled_ids = [...dis];
try { try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) }); await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ enabled }) });
_simsSettings.disabled_ids = disabled_ids; const s = _sims.find(x => x.id === simId);
if (s) s.enabled = enabled;
const card = document.getElementById('simcard-' + simId); const card = document.getElementById('simcard-' + simId);
if (card) card.classList.toggle('enabled', enabled); if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning'); LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function simToggleFeatured(simId, featured) {
try {
await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ featured }) });
const s = _sims.find(x => x.id === simId);
if (s) s.featured = featured;
_render();
LS.toast(featured ? `«${simId}» в рекомендуемых` : `«${simId}» убрана из рекомендуемых`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ── Фаза 5: редактор курикулумных связей (inline-панель под карточкой) ── */
async function _ensureTextbooks() {
if (_textbooks) return _textbooks;
try {
const data = await LS.api('/api/access/catalog');
_textbooks = (data && data.textbooks) || [];
} catch (e) { _textbooks = []; }
return _textbooks;
}
async function simToggleLinks(simId) {
const panel = document.getElementById('simlinks-' + simId);
if (!panel) return;
if (panel.style.display !== 'none') { panel.style.display = 'none'; return; }
panel.style.display = 'block';
panel.innerHTML = '<div style="font-size:.72rem;color:var(--text-3)">Загрузка связей…</div>';
try {
const [rel] = await Promise.all([
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'),
_ensureTextbooks(),
]);
_renderLinksPanel(simId, rel);
} catch (e) {
panel.innerHTML = '<div style="font-size:.72rem;color:var(--pink,#f15bb5)">Ошибка: ' + esc(e.message) + '</div>';
}
}
function _renderLinksPanel(simId, rel) {
const panel = document.getElementById('simlinks-' + simId);
if (!panel) return;
const links = (rel && rel.links) || {};
const tb = links.textbook || [];
let html = '<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-3);margin-bottom:6px">Связи с учебниками</div>';
if (tb.length) {
html += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
tb.forEach(l => {
html += `<span style="display:inline-flex;align-items:center;gap:6px;font-size:.72rem;padding:3px 6px 3px 10px;border-radius:999px;background:rgba(155,93,229,.14);color:var(--violet);border:1px solid rgba(155,93,229,.3)">
${esc(l.label || l.ref_id)}
<button title="Удалить связь" onclick="simDelLink('${esc(simId)}', ${Number(l.id)})"
style="background:none;border:none;cursor:pointer;color:inherit;padding:0;line-height:1;font-size:.95rem;opacity:.7">×</button>
</span>`;
});
html += '</div>';
} else {
html += '<div style="font-size:.72rem;color:var(--text-3);margin-bottom:8px">Пока нет связей</div>';
}
// add-form: textbook <select> + button
const linkedSlugs = new Set(tb.map(l => l.ref_id));
const opts = (_textbooks || [])
.filter(t => !linkedSlugs.has(t.slug))
.map(t => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('');
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<select id="simlink-sel-${esc(simId)}" style="flex:1;min-width:180px;font-size:.75rem;padding:5px 8px;border-radius:8px;background:var(--bg-2,#1a1a2e);color:var(--text);border:1px solid var(--border,rgba(255,255,255,.14))">
<option value="">— выбрать учебник —</option>${opts}
</select>
<button onclick="simAddLink('${esc(simId)}')"
style="font-size:.75rem;padding:5px 12px;border-radius:8px;border:1px solid var(--violet);background:rgba(155,93,229,.15);color:var(--violet);cursor:pointer">Добавить</button>
</div>`;
panel.innerHTML = html;
}
async function simAddLink(simId) {
const sel = document.getElementById('simlink-sel-' + simId);
const slug = sel && sel.value;
if (!slug) { LS.toast('Выберите учебник', 'warning'); return; }
try {
await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links',
{ method: 'POST', body: JSON.stringify({ kind: 'textbook', ref_id: slug }) });
LS.toast('Связь добавлена', 'success');
const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related');
_renderLinksPanel(simId, rel);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function simDelLink(simId, linkId) {
try {
await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links/' + linkId, { method: 'DELETE' });
LS.toast('Связь удалена', 'success');
const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related');
_renderLinksPanel(simId, rel);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
} }
window.simsMasterToggle = simsMasterToggle; window.simsMasterToggle = simsMasterToggle;
window.simToggleOne = simToggleOne; window.simToggleOne = simToggleOne;
window.simToggleFeatured = simToggleFeatured;
window.simToggleLinks = simToggleLinks;
window.simAddLink = simAddLink;
window.simDelLink = simDelLink;
window.AdminSections = window.AdminSections || {}; window.AdminSections = window.AdminSections || {};
window.AdminSections.sims = { window.AdminSections.sims = {
+107
View File
@@ -0,0 +1,107 @@
/* chem8_ch1_widgets.js — виджеты Главы 1 «Важнейшие классы неорганических соединений».
* Монтируются движком: window.CHEM8_WIDGETS[id] / window.FLAG_MOUNTS[id].
* Используют window.Chem8: classifier, indicatorScale, solubilityTable, activitySeries.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
function xp(n, s) { try { if (W.addXp) W.addXp(n, s); } catch (e) {} }
/* §10 — классификатор оксидов */
function mount_p10() {
var el = $('c-ox-cls'); if (!el || el._b || !C().classifier) return; el._b = 1;
C().classifier(el, {
items: [
{ id: 'Na2O', label: 'Na₂O', cat: 'осн' }, { id: 'CaO', label: 'CaO', cat: 'осн' },
{ id: 'CO2', label: 'CO₂', cat: 'кисл' }, { id: 'SO3', label: 'SO₃', cat: 'кисл' }, { id: 'P2O5', label: 'P₂O₅', cat: 'кисл' },
{ id: 'ZnO', label: 'ZnO', cat: 'амф' }, { id: 'Al2O3', label: 'Al₂O₃', cat: 'амф' },
{ id: 'CO', label: 'CO', cat: 'несол' }, { id: 'N2O', label: 'N₂O', cat: 'несол' }
],
buckets: [{ cat: 'осн', label: 'Основные' }, { cat: 'кисл', label: 'Кислотные' }, { cat: 'амф', label: 'Амфотерные' }, { cat: 'несол', label: 'Несолеобразующие' }],
onCheck: function (ok) { if (ok) xp(8, 'p10-cls'); }
});
}
/* §13 — классификатор кислот + индикатор */
function mount_p13() {
var cls = $('c-acid-cls');
if (cls && !cls._b && C().classifier) { cls._b = 1; C().classifier(cls, {
items: [
{ id: 'HCl', label: 'HCl', cat: 'без' }, { id: 'H2S', label: 'H₂S', cat: 'без' }, { id: 'HBr', label: 'HBr', cat: 'без' },
{ id: 'H2SO4', label: 'H₂SO₄', cat: 'кисл' }, { id: 'HNO3', label: 'HNO₃', cat: 'кисл' }, { id: 'H3PO4', label: 'H₃PO₄', cat: 'кисл' }
],
buckets: [{ cat: 'без', label: 'Бескислородные' }, { cat: 'кисл', label: 'Кислородсодержащие' }],
onCheck: function (ok) { if (ok) xp(8, 'p13-cls'); }
}); }
var ind = $('c-acid-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'лакмус', ph: 2 }); }
}
/* §14 — ряд активности + индикатор */
function mount_p14() {
var act = $('c-acid-act'); if (act && !act._b && C().activitySeries) { act._b = 1; C().activitySeries(act, {}); }
var ind = $('c-acid-ind2'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'метилоранж', ph: 2 }); }
}
/* §16 — классификатор оснований + индикатор (фенолфталеин) */
function mount_p16() {
var cls = $('c-base-cls');
if (cls && !cls._b && C().classifier) { cls._b = 1; C().classifier(cls, {
items: [
{ id: 'NaOH', label: 'NaOH', cat: 'щел' }, { id: 'KOH', label: 'KOH', cat: 'щел' }, { id: 'BaOH', label: 'Ba(OH)₂', cat: 'щел' },
{ id: 'CuOH', label: 'Cu(OH)₂', cat: 'нер' }, { id: 'FeOH', label: 'Fe(OH)₃', cat: 'нер' }, { id: 'MgOH', label: 'Mg(OH)₂', cat: 'нер' }
],
buckets: [{ cat: 'щел', label: 'Щёлочи (растворимые)' }, { cat: 'нер', label: 'Нерастворимые' }],
onCheck: function (ok) { if (ok) xp(8, 'p16-cls'); }
}); }
var ind = $('c-base-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'фенолфталеин', ph: 12 }); }
}
/* §17 — индикатор нейтрализации */
function mount_p17() { var ind = $('c-neutral-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'фенолфталеин', ph: 12 }); } }
/* §18 — индикатор (ПР2 нейтрализация) */
function mount_p18() { var ind = $('c-pr2-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'лакмус', ph: 7 }); } }
/* §19 — таблица растворимости */
function mount_p19() { var s = $('c-salt-sol'); if (s && !s._b && C().solubilityTable) { s._b = 1; C().solubilityTable(s, {}); } }
/* §20 — растворимость + ряд активности (соль + металл) */
function mount_p20() {
var s = $('c-salt-sol2'); if (s && !s._b && C().solubilityTable) { s._b = 1; C().solubilityTable(s, {}); }
var a = $('c-salt-act'); if (a && !a._b && C().activitySeries) { a._b = 1; C().activitySeries(a, {}); }
}
/* §23 — пошаговый решатель расчётных задач по классам */
var ST = [
{ eq: 'CaO + 2HCl → CaCl₂ + H₂O', given: 'Дано: m(CaO) = 28 г. Найти m(CaCl₂). M(CaO)=56, M(CaCl₂)=111.',
steps: ['n(CaO) = m/M = 28/56 = 0,5 моль.', 'n(CaO):n(CaCl₂) = 1:1 → n(CaCl₂)=0,5 моль.', 'm(CaCl₂) = n·M = 0,5·111 = 55,5 г. Ответ: 55,5 г.'] },
{ eq: 'Zn + H₂SO₄ → ZnSO₄ + H₂↑', given: 'Дано: n(Zn) = 2 моль. Найти V(H₂) при н.у.',
steps: ['n(Zn):n(H₂) = 1:1 → n(H₂)=2 моль.', 'V(H₂) = n·Vm = 2·22,4 = 44,8 л. Ответ: 44,8 л.'] },
{ eq: '2NaOH + H₂SO₄ → Na₂SO₄ + 2H₂O', given: 'Дано: n(H₂SO₄) = 0,5 моль. Найти n(NaOH).',
steps: ['n(NaOH):n(H₂SO₄) = 2:1 → n(NaOH)=2·0,5=1 моль.', 'Ответ: 1 моль NaOH.'] }
];
function mount_p23() {
var pick = $('c-calc-pick'), out = $('c-calc-out'), bStep = $('c-calc-step'), bAll = $('c-calc-all'); if (!pick || pick._b) return; pick._b = 1;
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="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
if (shown === 0) html += '<span style="color:var(--muted)">Нажмите «Следующий шаг».</span>';
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
}
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();
}
/* §22 — генетическая карта классов */
function mount_p22() { var el = $('c-genetic'); if (el && !el._b && C().geneticMap) { el._b = 1; C().geneticMap(el, {}); } }
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"ox","t":"Оксид","x":20,"y":22},{"id":"acid","t":"Кислота","x":160,"y":22,"c":"#2563eb"},{"id":"base","t":"Основание","x":20,"y":95,"c":"#0d9488"},{"id":"salt","t":"Соль","x":330,"y":55,"c":"#d97706"}],"edges":[{"f":"ox","t":"acid","label":"кислотный оксид + вода → кислота"},{"f":"acid","t":"base","label":"нейтрализация → соль + вода"},{"f":"acid","t":"salt","label":"кислота + металл/оксид → соль"},{"f":"base","t":"salt","label":"основание + кислота → соль"}]}); } }
W.CHEM8_WIDGETS = { p13: mount_p13, p16: mount_p16, p17: mount_p17, p18: mount_p18 };
W.FLAG_MOUNTS = { final1: mount_final1, p10: mount_p10, p14: mount_p14, p19: mount_p19, p20: mount_p20, p22: mount_p22, p23: mount_p23 };
})(window);
+76
View File
@@ -0,0 +1,76 @@
/* chem8_ch2_widgets.js — виджеты Главы 2 «Периодический закон и ПСХЭ».
* Использует window.Chem8: miniPeriodic, testTube.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
/* интерактивная ПСХЭ с кнопками-режимами подсветки */
function periodicModes(mountId, modes) {
var el = $(mountId); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
var bar = document.createElement('div'); bar.className = 'pt-modes';
var grid = document.createElement('div');
modes.forEach(function (m, i) {
var b = document.createElement('button'); b.className = 'btn'; b.textContent = m.l;
b.addEventListener('click', function () {
bar.querySelectorAll('.btn').forEach(function (x) { x.classList.remove('primary'); });
b.classList.add('primary'); if (api) api.highlight(m.k);
});
bar.appendChild(b);
});
el.appendChild(bar); el.appendChild(grid);
var api = C().miniPeriodic(grid, {});
var legend = document.createElement('div'); legend.className = 'pt-legend';
legend.innerHTML = '<span><i style="background:rgba(13,148,136,.4)"></i>металлы</span><span><i style="background:rgba(245,158,11,.5)"></i>неметаллы</span><span><i style="background:rgba(124,58,237,.4)"></i>металлоиды</span><span><i style="background:rgba(37,99,235,.4)"></i>инертные</span>';
el.appendChild(legend);
}
function mount_p24() {
periodicModes('c-pt-metals', [
{ k: 'metals', l: 'Металлы' }, { k: 'nonmetals', l: 'Неметаллы' }, { k: 'metalloids', l: 'Металлоиды' }, { k: null, l: 'Сброс' }
]);
}
function mount_p26() {
periodicModes('c-pt-fam', [
{ k: 'alkali', l: 'Щелочные' }, { k: 'alkaline', l: 'Щёлочноземельные' }, { k: 'halogens', l: 'Галогены' }, { k: 'noble', l: 'Инертные газы' }, { k: null, l: 'Сброс' }
]);
}
function mount_p28() {
periodicModes('c-pt-struct', [
{ k: { period: 2 }, l: 'Период 2' }, { k: { period: 3 }, l: 'Период 3' }, { k: { group: 1 }, l: 'Группа I' }, { k: { group: 17 }, l: 'Группа VII' }, { k: null, l: 'Сброс' }
]);
}
/* §25 — амфотерность: Zn(OH)₂ растворяется и в кислоте, и в щёлочи */
function mount_p25() {
var el = $('c-amph'); if (!el || el._b) return; el._b = 1;
el.innerHTML =
'<div class="amph-row">' +
'<button class="btn amph-btn" data-r="acid">+ кислота (HCl)</button>' +
'<button class="btn amph-btn" data-r="base">+ щёлочь (NaOH)</button>' +
'<button class="btn amph-reset">Сначала</button>' +
'</div>' +
'<div class="amph-stage"></div>' +
'<div class="out amph-out">Zn(OH)₂ — амфотерный гидроксид. Добавь кислоту или щёлочь и посмотри, что будет.</div>';
var stage = el.querySelector('.amph-stage'), out = el.querySelector('.amph-out');
function tt(o) { return C().testTube ? C().testTube(o) : ''; }
function reset() { stage.innerHTML = '<div style="text-align:center;color:#0f766e">' + tt({ color: '#fff', precipitate: '#cbd5e1', label: 'Zn(OH)2' }) + '</div><div class="tt-cap" style="margin:0 auto">Белый осадок Zn(OH)₂</div>'; out.className = 'out amph-out'; out.innerHTML = 'Zn(OH)₂ — белый студенистый осадок (амфотерный гидроксид).'; }
el.querySelectorAll('.amph-btn').forEach(function (b) {
b.addEventListener('click', function () {
var r = b.getAttribute('data-r');
stage.innerHTML = '<div style="text-align:center;color:#0f766e">' + tt({ color: '#dbeafe' }) + '</div><div class="tt-cap" style="margin:0 auto">Осадок растворился</div>';
out.className = 'out amph-out ok';
out.innerHTML = r === 'acid'
? 'Как <b>основание</b>: Zn(OH)₂ + 2HCl → ZnCl₂ + 2H₂O — осадок растворился.'
: 'Как <b>кислота</b>: Zn(OH)₂ + 2NaOH → Na₂[Zn(OH)₄] — осадок растворился (амфотерность!).';
});
});
el.querySelector('.amph-reset').addEventListener('click', reset);
reset();
}
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"per","t":"Период","x":20,"y":22},{"id":"grp","t":"Группа","x":20,"y":95},{"id":"fam","t":"Семейство","x":160,"y":55},{"id":"prop","t":"Свойства","x":330,"y":55}],"edges":[{"f":"per","t":"prop","label":"номер периода = число слоёв"},{"f":"grp","t":"prop","label":"номер группы = внешние e⁻"},{"f":"fam","t":"grp","label":"одна группа — одно семейство"}]}); } }
W.CHEM8_WIDGETS = { p25: mount_p25 };
W.FLAG_MOUNTS = { final1: mount_final1, p24: mount_p24, p26: mount_p26, p28: mount_p28 };
})(window);
+98
View File
@@ -0,0 +1,98 @@
/* chem8_ch3_widgets.js — виджеты Главы 3 «Строение атома».
* Использует window.Chem8: atomShell, shellConfig, nuclide, zSym, miniPeriodic, arOf.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
/* §29 — модель атома */
function mount_p29() { var el = $('c-atom'); if (el && !el._b && C().atomShell) { el._b = 1; C().atomShell(el, { z: 11 }); } }
/* §30 — нуклид: A = Z + N */
function mount_p30() {
var el = $('c-nuclide'); if (!el || el._b) return; el._b = 1;
el.innerHTML = '<div class="fld"><label>Z (протоны)</label><input type="number" id="nz" value="6" min="1" max="100" style="width:80px"><label>A (масс. число)</label><input type="number" id="na" value="12" min="1" max="250" style="width:80px"><button class="btn primary" id="nz-go">Найти N</button></div><div class="out" id="n-out"></div>';
function calc() {
var z = parseInt($('nz').value, 10), a = parseInt($('na').value, 10);
if (isNaN(z) || isNaN(a) || a < z) { $('n-out').className = 'out bad'; $('n-out').textContent = 'Проверь: A не может быть меньше Z.'; return; }
var nu = C().nuclide(z, a);
$('n-out').className = 'out ok';
$('n-out').innerHTML = '<span class="bd">Элемент: <b>' + nu.sym + '</b><br>Протонов Z = ' + z + '<br>Нейтронов N = A Z = ' + a + ' ' + z + ' = <b>' + nu.N + '</b><br>Нуклид: ' + nu.sym + '-' + a + '</span>';
}
$('nz-go').addEventListener('click', calc); calc();
}
/* §31 — средняя Ar по изотопам */
function mount_p31() {
var el = $('c-iso'); if (!el || el._b) return; el._b = 1;
el.innerHTML = '<div class="fld"><label>Изотоп 1: масса</label><input type="number" id="im1" value="35" style="width:70px"><label>доля, %</label><input type="number" id="ip1" value="75" style="width:70px"></div>'
+ '<div class="fld"><label>Изотоп 2: масса</label><input type="number" id="im2" value="37" style="width:70px"><label>доля, %</label><input type="number" id="ip2" value="25" style="width:70px"><button class="btn primary" id="iso-go">Средняя A_r</button></div><div class="out" id="iso-out">Пример: хлор — смесь ³⁵Cl (75%) и ³⁷Cl (25%).</div>';
function calc() {
var m1 = parseFloat($('im1').value), p1 = parseFloat($('ip1').value), m2 = parseFloat($('im2').value), p2 = parseFloat($('ip2').value);
if ([m1, p1, m2, p2].some(isNaN)) { $('iso-out').className = 'out bad'; $('iso-out').textContent = 'Введите все значения.'; return; }
var ar = (m1 * p1 + m2 * p2) / (p1 + p2);
$('iso-out').className = 'out ok';
$('iso-out').innerHTML = '<span class="bd">A_r = (' + m1 + '·' + p1 + ' + ' + m2 + '·' + p2 + ') / 100 = <b>' + (Math.round(ar * 100) / 100).toString().replace('.', ',') + '</b></span>';
}
$('iso-go').addEventListener('click', calc); calc();
}
/* §33 — строение электронных оболочек (та же модель, акцент на слои) */
function mount_p33() { var el = $('c-shells'); if (el && !el._b && C().atomShell) { el._b = 1; C().atomShell(el, { z: 17 }); } }
/* §34 — периодичность: ПСХЭ с подсветкой периодов/групп */
function mount_p34() {
var el = $('c-trend'); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
var modes = [{ k: { period: 2 }, l: 'Период 2 →' }, { k: { period: 3 }, l: 'Период 3 →' }, { k: { group: 1 }, l: 'Группа I ↓' }, { k: { group: 17 }, l: 'Группа VII ↓' }, { k: null, l: 'Сброс' }];
var bar = document.createElement('div'); bar.className = 'pt-modes';
var grid = document.createElement('div'), note = document.createElement('div'); note.className = 'out';
var TXT = {
'p2': 'По периоду слева направо: радиус атома уменьшается, металлические свойства ослабевают, неметаллические — усиливаются.',
'p3': 'То же в 3-м периоде: от активного металла Na к активному неметаллу Cl.',
'g1': 'Вниз по группе: радиус растёт, металлические свойства усиливаются (Li → Na → K → ...).',
'g17': 'Вниз по группе галогенов: неметаллические свойства ослабевают (F самый активный).'
};
modes.forEach(function (m) {
var b = document.createElement('button'); b.className = 'btn'; b.textContent = m.l;
b.addEventListener('click', function () {
bar.querySelectorAll('.btn').forEach(function (x) { x.classList.remove('primary'); }); b.classList.add('primary');
if (api) api.highlight(m.k);
var key = m.k ? (m.k.period ? 'p' + m.k.period : 'g' + m.k.group) : null;
note.textContent = key && TXT[key] ? TXT[key] : 'Выбери период или группу — увидишь тренд свойств.';
});
bar.appendChild(b);
});
el.appendChild(bar); el.appendChild(grid); el.appendChild(note);
var api = C().miniPeriodic(grid, {});
note.textContent = 'Выбери период или группу — увидишь, как меняются свойства.';
}
/* §35 — паспорт элемента: клик в ПСХЭ → полная характеристика */
function mount_p35() {
var el = $('c-passport'); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
var grid = document.createElement('div'), panel = document.createElement('div'); panel.className = 'passport';
panel.innerHTML = '<h4>Паспорт элемента</h4><div style="color:var(--muted);font-size:.85rem">Кликни элемент в системе.</div>';
el.appendChild(grid); el.appendChild(panel);
C().miniPeriodic(grid, { onClick: function (sym, info) {
var sh = C().shellConfig(info.z);
var catRu = info.cat === 'metal' ? 'металл' : info.cat === 'nonmetal' ? 'неметалл' : info.cat === 'metalloid' ? 'металлоид' : 'инертный газ';
panel.innerHTML = '<h4>Паспорт: ' + sym + '</h4><div class="passport-grid">'
+ '<div><b>Z</b>: ' + info.z + '</div>'
+ '<div><b>A_r</b>: ' + (info.ar || '—') + '</div>'
+ '<div><b>Период</b>: ' + info.p + '</div>'
+ '<div><b>Группа</b>: ' + info.g + '</div>'
+ '<div><b>Тип</b>: ' + catRu + '</div>'
+ '<div><b>Протонов</b>: ' + info.z + '</div>'
+ '<div><b>Электронов</b>: ' + info.z + '</div>'
+ '<div><b>Слои e⁻</b>: ' + sh.join(' ) ') + '</div>'
+ '<div><b>Внешних e⁻</b>: ' + sh[sh.length - 1] + '</div>'
+ '</div>';
if (W.chem8RenderMath) try { W.chem8RenderMath(panel); } catch (e) {}
} });
}
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"nuc","t":"Ядро","x":20,"y":55},{"id":"prot","t":"Протоны","x":170,"y":22},{"id":"neut","t":"Нейтроны","x":170,"y":95},{"id":"elec","t":"Электроны","x":330,"y":55}],"edges":[{"f":"nuc","t":"prot","label":"Z = число протонов"},{"f":"nuc","t":"neut","label":"N нейтронов (A = Z + N)"},{"f":"prot","t":"elec","label":"Z = e⁻ (атом нейтрален)"}]}); } }
W.CHEM8_WIDGETS = { p29: mount_p29, p30: mount_p30, p31: mount_p31, p33: mount_p33 };
W.FLAG_MOUNTS = { final1: mount_final1, p34: mount_p34, p35: mount_p35 };
})(window);
+20
View File
@@ -0,0 +1,20 @@
/* chem8_ch4_widgets.js — виджеты Главы 4 «Химическая связь».
* Использует window.Chem8: bondType.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
function M() { return W.Chem8Mol; }
function mount_p37() { var el = $('c-bond1'); if (el && !el._b && C().bondType) { el._b = 1; C().bondType(el, { a: 'H', b: 'H' }); } }
function mount_p38() {
var el = $('c-bond2'); if (el && !el._b && C().bondType) { el._b = 1; C().bondType(el, { a: 'H', b: 'Cl' }); }
var mol = $('c-mol'); if (mol && !mol._b && M()) { mol._b = 1; M().molModel(mol, 'H2O'); }
}
function mount_p41() { var el = $('c-lattice'); if (el && !el._b && M()) { el._b = 1; M().latticeViewer(el, 'ionic'); } }
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"cov","t":"Ковалент.","x":20,"y":22},{"id":"ion","t":"Ионная","x":20,"y":95},{"id":"met","t":"Металлич.","x":160,"y":55},{"id":"lat","t":"Решётка","x":330,"y":22},{"id":"prop","t":"Свойства","x":330,"y":95}],"edges":[{"f":"cov","t":"lat","label":"ковалентная → атомная/молек. решётка"},{"f":"ion","t":"lat","label":"ионная → ионная решётка"},{"f":"met","t":"lat","label":"металлическая → металл. решётка"},{"f":"lat","t":"prop","label":"тип решётки определяет свойства"}]}); } }
W.CHEM8_WIDGETS = {};
W.FLAG_MOUNTS = { final1: mount_final1, p37: mount_p37, p38: mount_p38, p41: mount_p41 };
})(window);
+59
View File
@@ -0,0 +1,59 @@
/* chem8_ch5_widgets.js — виджеты Главы 5 «Окислительно-восстановительные реакции».
* Использует window.Chem8: oxStateCalc.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
/* §42 — калькулятор степени окисления */
function mount_p42() { var el = $('c-ox'); if (el && !el._b && C().oxStateCalc) { el._b = 1; C().oxStateCalc(el, { formula: 'H2SO4' }); } }
/* §44 — пошаговый электронный баланс (преднабор) */
var R = [
{ eq: '2Mg + O₂ → 2MgO',
steps: [
'Степени окисления: Mg⁰, O₂⁰ → Mg⁺², O⁻².',
'Mg⁰ − 2e⁻ → Mg⁺² — окисление (Mg — восстановитель).',
'O₂⁰ + 4e⁻ → 2O⁻² — восстановление (O₂ — окислитель).',
'Электронный баланс: отдано 2e⁻ (×2 = 4), принято 4e⁻ → множители 2 и 1.',
'Коэффициенты: 2Mg + O₂ → 2MgO. ✓'
] },
{ eq: 'Fe + CuSO₄ → FeSO₄ + Cu',
steps: [
'Меняют с.о. только Fe и Cu: Fe⁰ → Fe⁺², Cu⁺² → Cu⁰.',
'Fe⁰ − 2e⁻ → Fe⁺² — окисление (Fe — восстановитель).',
'Cu⁺² + 2e⁻ → Cu⁰ — восстановление (Cu⁺² — окислитель).',
'Отдано 2e⁻ = принято 2e⁻ → множители 1 и 1.',
'Коэффициенты: Fe + CuSO₄ → FeSO₄ + Cu. ✓'
] },
{ eq: '2Na + Cl₂ → 2NaCl',
steps: [
'Na⁰ и Cl₂⁰ → Na⁺ и Cl⁻.',
'Na⁰ − 1e⁻ → Na⁺ — окисление (Na — восстановитель).',
'Cl₂⁰ + 2e⁻ → 2Cl⁻ — восстановление (Cl₂ — окислитель).',
'Баланс: 1e⁻ ×2 = 2e⁻ → множители 2 и 1.',
'Коэффициенты: 2Na + Cl₂ → 2NaCl. ✓'
] }
];
function mount_p44() {
var pick = $('c-redox-pick'), out = $('c-redox-out'), bStep = $('c-redox-step'), bAll = $('c-redox-all'); if (!pick || pick._b) return; pick._b = 1;
R.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 = R[cur];
var html = '<b>' + p.eq + '</b><div style="margin-top:8px">';
for (var i = 0; i < shown; i++) html += '<div class="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
if (shown === 0) html += '<span style="color:var(--muted)">Нажимай «Следующий шаг» — разберём метод электронного баланса.</span>';
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
}
pick.addEventListener('change', function () { cur = +pick.value; shown = 0; render(); });
bStep.addEventListener('click', function () { if (shown < R[cur].steps.length) { shown++; render(); } });
bAll.addEventListener('click', function () { shown = R[cur].steps.length; render(); });
render();
}
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"so","t":"Степ. ок.","x":20,"y":55},{"id":"oxi","t":"Окисление","x":170,"y":22},{"id":"red","t":"Восстан.","x":170,"y":95},{"id":"bal","t":"Баланс","x":330,"y":55,"c":"#d97706"}],"edges":[{"f":"so","t":"oxi","label":"с.о. растёт (отдача e⁻)"},{"f":"so","t":"red","label":"с.о. падает (приём e⁻)"},{"f":"oxi","t":"bal","label":"отдано e⁻"},{"f":"red","t":"bal","label":"= принято e⁻"}]}); } }
W.CHEM8_WIDGETS = { p42: mount_p42 };
W.FLAG_MOUNTS = { final1: mount_final1, p44: mount_p44 };
})(window);
+85
View File
@@ -0,0 +1,85 @@
/* chem8_ch6_widgets.js — виджеты Главы 6 «Растворы».
* Использует window.Chem8: classifier, solubilityTable, molarMass.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
function rr(v, d) { var p = Math.pow(10, d == null ? 2 : d); return (Math.round(v * p) / p).toString().replace('.', ','); }
/* §46 — классификатор смесей */
function mount_p46() {
var el = $('c-mix'); if (!el || el._b || !C().classifier) return; el._b = 1;
C().classifier(el, {
items: [
{ id: 'air', label: 'воздух', cat: 'odn' }, { id: 'saltsol', label: 'раствор соли', cat: 'odn' }, { id: 'steel', label: 'сталь', cat: 'odn' },
{ id: 'sandwater', label: 'песок + вода', cat: 'neod' }, { id: 'milk', label: 'молоко', cat: 'neod' }, { id: 'granite', label: 'гранит', cat: 'neod' }
],
buckets: [{ cat: 'odn', label: 'Однородные (растворы)' }, { cat: 'neod', label: 'Неоднородные' }],
onCheck: function (ok) { if (ok && W.addXp) W.addXp(8, 'p46-mix'); }
});
}
/* §48 — кривая растворимости s = f(t) */
var CURVE = { KNO3: [13, 21, 32, 46, 64, 88, 110, 138, 169, 202, 246], NaCl: [35.7, 35.8, 36, 36.3, 36.6, 37, 37.3, 37.8, 38.4, 39, 39.8] };
function mount_p48() {
var el = $('c-solcurve'); if (!el || el._b) return; el._b = 1;
el.innerHTML = '<div class="fld"><label>Вещество</label><select id="sc-sub"><option value="KNO3">KNO₃ (нитрат калия)</option><option value="NaCl">NaCl (соль)</option></select><label>t, °C</label><input type="range" id="sc-t" min="0" max="100" step="10" value="40"><span class="bd" id="sc-tv"></span></div><div id="sc-plot"></div><div class="out" id="sc-out"></div>';
var sub = $('sc-sub'), tr = $('sc-t'), tv = $('sc-tv'), plot = $('sc-plot'), out = $('sc-out');
function draw() {
var data = CURVE[sub.value], t = +tr.value, idx = t / 10, s = data[idx];
tv.textContent = t + ' °C';
var maxS = Math.max.apply(null, CURVE.KNO3);
var W0 = 280, H0 = 140, pad = 24;
var pts = data.map(function (v, i) { var x = pad + i / 10 * (W0 - pad * 2); var y = H0 - pad - v / maxS * (H0 - pad * 2); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' ');
var cx = pad + idx / 10 * (W0 - pad * 2), cy = H0 - pad - s / maxS * (H0 - pad * 2);
plot.innerHTML = '<svg viewBox="0 0 ' + W0 + ' ' + H0 + '" style="width:100%;max-width:340px;color:var(--pri)">'
+ '<line x1="' + pad + '" y1="' + (H0 - pad) + '" x2="' + (W0 - pad) + '" y2="' + (H0 - pad) + '" stroke="currentColor" opacity=".4"/>'
+ '<line x1="' + pad + '" y1="' + pad + '" x2="' + pad + '" y2="' + (H0 - pad) + '" stroke="currentColor" opacity=".4"/>'
+ '<polyline points="' + pts + '" fill="none" stroke="var(--pri)" stroke-width="2"/>'
+ '<circle cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="5" fill="var(--pri)"/>'
+ '<text x="' + (W0 - pad) + '" y="' + (H0 - 6) + '" text-anchor="end" font-size="9" fill="currentColor">t, °C</text>'
+ '<text x="' + pad + '" y="' + (pad - 8) + '" font-size="9" fill="currentColor">s, г/100г</text>'
+ '</svg>';
out.className = 'out ok';
out.innerHTML = '<span class="bd">При ' + t + ' °C растворимость ' + sub.value + ' ≈ <b>' + rr(s, 1) + ' г</b> на 100 г воды.' + (sub.value === 'KNO3' ? ' Растворимость сильно растёт с температурой.' : ' У NaCl почти не зависит от t.') + '</span>';
}
sub.addEventListener('change', draw); tr.addEventListener('input', draw); draw();
}
/* §50 — массовая доля w */
function mount_p50() {
var el = $('c-wcalc'); if (!el || el._b) return; el._b = 1;
el.innerHTML = '<div class="fld"><label>m(вещества), г</label><input type="number" id="w-ms" value="20" style="width:80px"><label>m(воды), г</label><input type="number" id="w-mw" value="80" style="width:80px"><button class="btn primary" id="w-go">Найти w</button></div><div class="out" id="w-out"></div>';
function calc() {
var ms = parseFloat($('w-ms').value), mw = parseFloat($('w-mw').value);
if (isNaN(ms) || isNaN(mw) || ms + mw <= 0) { $('w-out').className = 'out bad'; $('w-out').textContent = 'Введите массы.'; return; }
var w = ms / (ms + mw) * 100;
$('w-out').className = 'out ok';
$('w-out').innerHTML = '<span class="bd">m(раствора) = ' + ms + ' + ' + mw + ' = ' + (ms + mw) + ' г<br>w = m(в-ва)/m(р-ра) = ' + ms + '/' + (ms + mw) + ' = <b>' + rr(w, 1) + ' %</b></span>';
}
$('w-go').addEventListener('click', calc); calc();
}
/* §51 — молярная концентрация c = n/V */
function mount_p51() {
var el = $('c-ccalc'); if (!el || el._b) return; el._b = 1;
el.innerHTML = '<div class="fld"><label>Вещество</label><input type="text" id="c-f" value="NaOH" style="width:110px;font-family:var(--mono)"><label>m, г</label><input type="number" id="c-m" value="40" style="width:70px"><label>V, л</label><input type="number" id="c-v" value="1" step="0.1" style="width:70px"><button class="btn primary" id="c-go">Найти c</button></div><div class="out" id="c-out"></div>';
function calc() {
var f = $('c-f').value.trim(), M = C().molarMass ? C().molarMass(f) : NaN, m = parseFloat($('c-m').value), V = parseFloat($('c-v').value);
if (isNaN(M)) { $('c-out').className = 'out bad'; $('c-out').textContent = 'Не удалось разобрать формулу.'; return; }
if (isNaN(m) || isNaN(V) || V <= 0) { $('c-out').className = 'out bad'; $('c-out').textContent = 'Введите m и V.'; return; }
var n = m / M, c = n / V;
$('c-out').className = 'out ok';
$('c-out').innerHTML = '<span class="bd">M(' + f + ') = ' + M + ' г/моль<br>n = m/M = ' + m + '/' + M + ' = ' + rr(n) + ' моль<br>c = n/V = ' + rr(n) + '/' + rr(V) + ' = <b>' + rr(c) + ' моль/л</b></span>';
}
$('c-go').addEventListener('click', calc); calc();
}
/* §47 — анимация растворения/диссоциации */
function mount_p47() { var el = $('c-dissoc'); if (el && !el._b && C().dissociationAnim) { el._b = 1; C().dissociationAnim(el, { substance: 'NaCl' }); } }
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"mix","t":"Смесь","x":20,"y":55},{"id":"sol","t":"Раствор","x":170,"y":55,"c":"#0891b2"},{"id":"sb","t":"Растворим.","x":330,"y":22},{"id":"w","t":"w (доля)","x":330,"y":55},{"id":"c","t":"c (моль/л)","x":330,"y":95}],"edges":[{"f":"mix","t":"sol","label":"однородная смесь = раствор"},{"f":"sol","t":"sb","label":"растворимость: г / 100 г воды"},{"f":"sol","t":"w","label":"массовая доля w = m / m"},{"f":"sol","t":"c","label":"молярная концентрация c = n/V"}]}); } }
W.CHEM8_WIDGETS = { p46: mount_p46, p50: mount_p50, p51: mount_p51 };
W.FLAG_MOUNTS = { final1: mount_final1, p47: mount_p47, p48: mount_p48 };
})(window);
+430
View File
@@ -0,0 +1,430 @@
/* chem8_engine.js — общий движок интерактивных учебников «Химия 8».
*
* Воспроизводит каркас учебников физики: SPA с para-selector, ленивая сборка §,
* карточки теории (makeCard), тренажёр задач (числовой ввод + MCQ), sidebar-шпаргалка,
* прогресс/XP/уровни/достижения, серверная синхронизация прогресса, тема.
*
* Страница главы ОБЪЯВЛЯЕТ данные (до загрузки движка, инлайн-скриптом):
* window.CHEM8_CFG = { slug, themeKey, xpKey, progKey, achKey, hubHref }
* window.PARAS = [{id, num, name, sub, final?}]
* window.BUILDERS = { p1: ()=>build_p1(), ... } // наполняют #<id>-body
* window.POOLS = { p1: [task,...], ... } // task: {q,hint,unit,a,ex,tol} | {q,opts,a,ex}
* window.SIDEBARS = { p1: {title, rows:[[k,v],...]}, ... }
* window.TIPS = [{sec, html}, ...]
* window.CHEM8_WIDGETS = { p1: ()=>add_p1(), ... } // монтаж виджетов §
* window.FLAG_MOUNTS = { p6: ()=>mountFlag('p6'), ... } // флагман-интерактивы
* window.ACH_LABELS = { start, p1_done, ... }
*
* Движок ЭКСПОРТИРУЕТ на window: goTo, checkNum, selectMcq, nextTask, goToTask,
* resetTasks, makeCard, secNav, readButton, addXp, achievement, bumpProgress.
* Инициализация — на DOMContentLoaded.
*/
(function (W) {
'use strict';
// Конфиг резолвится лениво в init() — страница задаёт window.CHEM8_CFG
// в body-скрипте, который при defer выполняется до движка, но не полагаемся на это.
var CFG = {}, SLUG = 'chemistry-8';
var K = { theme: 'chemistry8_theme', xp: 'chemistry8_xp', prog: 'chemistry-8_progress', ach: 'chemistry-8_ach' };
function resolveCfg() {
CFG = W.CHEM8_CFG || {};
SLUG = CFG.slug || 'chemistry-8';
K = {
theme: CFG.themeKey || 'chemistry8_theme',
xp: CFG.xpKey || 'chemistry8_xp',
prog: CFG.progKey || (SLUG + '_progress'),
ach: CFG.achKey || (SLUG + '_ach')
};
}
function PARAS() { return W.PARAS || []; }
function POOLS() { return W.POOLS || {}; }
function BUILDERS(){ return W.BUILDERS || {}; }
function ACHL() { return W.ACH_LABELS || {}; }
var STATE = { current: null, progress: {}, achievements: new Map(), xp: 0, level: 1 };
var SEC = {}; // STATE задач по секциям
/* ── XP / уровни ───────────────────────────────────────────────── */
function calcLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
function xpForLevel(lv) { return (lv - 1) * (lv - 1) * 100; }
function loadProgress() {
try {
var s = localStorage.getItem(K.prog); if (s) Object.assign(STATE.progress, JSON.parse(s));
var a = localStorage.getItem(K.ach);
if (a) { var p = JSON.parse(a); if (p && typeof p === 'object') for (var id in p) STATE.achievements.set(id, p[id]); }
STATE.xp = parseInt(localStorage.getItem(K.xp) || '0', 10) || 0;
STATE.level = calcLevel(STATE.xp);
} catch (e) {}
}
function saveProgress() {
try {
localStorage.setItem(K.prog, JSON.stringify(STATE.progress));
localStorage.setItem(K.ach, JSON.stringify(mapToObj(STATE.achievements)));
localStorage.setItem(K.xp, String(STATE.xp));
} catch (e) {}
}
function mapToObj(m) { var o = {}; m.forEach(function (v, k) { o[k] = v; }); return o; }
function addXp(n, src) {
if (!n) return;
var prev = STATE.level;
STATE.xp = Math.max(0, (STATE.xp || 0) + n); STATE.level = calcLevel(STATE.xp);
saveProgress(); refreshUI();
try { if (W.LS && W.LS.xp && W.LS.xp.add) W.LS.xp.add(n, SLUG + '-' + (src || 'x')); } catch (e) {}
if (STATE.level > prev) popup('Уровень ' + STATE.level + '!');
}
function bumpProgress(key, delta) {
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key] || 0) + delta));
saveProgress(); refreshUI();
if (STATE.progress[key] >= 50) markServerRead(key);
}
function achievement(id, text) {
if (STATE.achievements.has(id)) return;
var label = text || ACHL()[id] || id;
STATE.achievements.set(id, label); saveProgress();
popup(label, true);
addXp(20, 'ach-' + id);
}
/* ── серверная синхронизация ───────────────────────────────────── */
var _marked = {}, _pending = null, _timer = null;
function _flush() {
var body = _pending; _pending = null; if (!body) return;
var tok = (W.LS && W.LS.getToken) ? W.LS.getToken() : ''; if (!tok) return;
fetch('/api/textbooks/' + 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[id] || /^final/.test(id)) return; _marked[id] = 1; _queue({ mark_read: id }); }
function markLastPara(id) { _queue({ last_para: id }); }
function loadServerReadState() {
var tok = (W.LS && W.LS.getToken) ? W.LS.getToken() : ''; if (!tok) return;
fetch('/api/textbooks/' + SLUG, { headers: { 'Authorization': 'Bearer ' + tok } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.progress || !d.progress.read) return;
d.progress.read.forEach(function (k) { _marked[k] = 1; if ((STATE.progress[k] || 0) < 50) STATE.progress[k] = 100; });
saveProgress(); refreshUI();
}).catch(function () {});
}
W.addEventListener('beforeunload', _flush);
/* ── popup ачивки / уровня ─────────────────────────────────────── */
function popup(text, gold) {
var pop = document.getElementById('ach-popup'); if (!pop) return;
var t = document.getElementById('ach-text'); if (t) t.textContent = text;
pop.classList.toggle('gold', !!gold);
pop.classList.add('show'); setTimeout(function () { pop.classList.remove('show'); }, 3000);
if (gold) { try { if (W.confetti) W.confetti({ particleCount: 160, spread: 95, origin: { y: .65 } }); } catch (e) {} }
}
/* ── para-selector + hero ──────────────────────────────────────── */
function buildParaSelector() {
var g = document.getElementById('psel-grid'); if (!g) return;
g.innerHTML = '';
PARAS().forEach(function (p) {
var card = document.createElement('div');
card.className = 'psel-card' + (p.final ? ' final' : '');
card.dataset.id = p.id; card.dataset.progCard = p.id;
card.innerHTML = '<div class="psel-num">' + p.num + '</div><div class="psel-name">' + p.name + '</div>'
+ (p.sub ? '<div class="psel-sub">' + p.sub + '</div>' : '')
+ '<div class="psel-prog"><div class="psel-prog-fill"></div></div>'
+ '<span class="psel-done"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>';
card.addEventListener('click', function () { goTo(p.id); });
g.appendChild(card);
});
if (W.renderMathInElement) try { renderMath(g); } catch (e) {}
}
function refreshUI() {
var total = PARAS().length || 1;
var sum = 0; PARAS().forEach(function (p) { sum += (STATE.progress[p.id] || 0); });
var pct = Math.round(sum / total);
var hf = document.getElementById('hero-hp-fill'); if (hf) hf.style.width = pct + '%';
var ht = document.getElementById('hero-hp-text'); if (ht) ht.textContent = pct + '%';
var xb = document.getElementById('hero-xp-badge');
if (xb) xb.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. ' + STATE.level + ' \xb7 ' + (STATE.xp || 0) + ' XP';
document.querySelectorAll('.psel-card').forEach(function (c) {
var id = c.dataset.id; var pp = STATE.progress[id] || 0;
var fl = c.querySelector('.psel-prog-fill'); if (fl) fl.style.width = pp + '%';
c.classList.toggle('done', pp >= 50);
});
if (STATE.current && document.getElementById('sidebar-content')) { try { buildSidebar(STATE.current); } catch (e) {} }
}
/* ── ленивая сборка § + инъекция задач ─────────────────────────── */
var BUILT = {};
function ensureBuilt(id) {
if (BUILT[id]) return;
var fn = BUILDERS()[id];
if (fn) { try { fn(); } catch (e) { if (W.console) console.warn('build ' + id, e.message); } BUILT[id] = 1; }
_injectTasks(id);
_mountWidgets(id);
}
function _mountWidgets(id) {
setTimeout(function () {
try { if (W.CHEM8_WIDGETS && W.CHEM8_WIDGETS[id]) W.CHEM8_WIDGETS[id](); } catch (e) { if (W.console) console.warn('widget ' + id, e.message); }
try { if (W.FLAG_MOUNTS && W.FLAG_MOUNTS[id]) W.FLAG_MOUNTS[id](); } catch (e) { if (W.console) console.warn('flag ' + id, e.message); }
}, 40);
}
function _makeTaskBlock(sec) {
return '<div class="legacy-tasks" id="ptab-' + sec + '">'
+ '<div class="lt-head"><span class="lt-title">Задачи параграфа</span>'
+ '<span class="chip chip-ok"><span id="ok' + sec + '">0</span> верно</span>'
+ '<span class="chip chip-tot"><span id="cur' + sec + '">0</span>/<span id="max' + sec + '">?</span></span>'
+ '<button class="btn lt-reset" onclick="resetTasks(\'' + sec + '\')">Заново</button></div>'
+ '<div class="prog-wrap"><div id="prog' + sec + '" class="prog-fill"></div></div>'
+ '<div class="nav-dots" id="navDots' + sec + '"></div>'
+ '<div id="taskArea' + sec + '"></div>'
+ '<div class="feedback" id="fb' + sec + '"></div>'
+ '<div class="lt-foot"><button class="btn primary" id="nextBtn' + sec + '" onclick="nextTask(\'' + sec + '\')" style="display:none">Следующая &rarr;</button></div>'
+ '<div class="summary" id="sum' + sec + '"><div class="sum-t">Параграф пройден!</div><div class="big-score" id="sumScore' + sec + '"></div><div class="sum-grade" id="sumGrade' + sec + '"></div></div>'
+ '</div>';
}
function _injectTasks(id) {
var pool = POOLS()[id]; if (!pool) return;
var body = document.getElementById(id + '-body'); if (!body || body.querySelector('.legacy-tasks')) return;
if (!SEC[id]) SEC[id] = { idx: 0, results: pool.map(function () { return null; }), selections: pool.map(function () { return null; }), answered: false };
body.insertAdjacentHTML('beforeend', _makeTaskBlock(id));
setTimeout(function () { try { renderTask(id); } catch (e) {} }, 50);
}
/* ── навигация по § ────────────────────────────────────────────── */
function goTo(id) {
STATE.current = id; ensureBuilt(id);
document.querySelectorAll('.sec').forEach(function (s) { s.classList.remove('active'); });
var el = document.getElementById('sec-' + id); if (el) el.classList.add('active');
document.querySelectorAll('.psel-card').forEach(function (c) { c.classList.toggle('active', c.dataset.id === id); });
buildSidebar(id);
try { W.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) {}
if ((STATE.progress[id] || 0) < 10) bumpProgress(id, 10);
if (W.renderMathInElement && el) setTimeout(function () { renderMath(el); }, 0);
markLastPara(id);
}
/* ── sidebar ───────────────────────────────────────────────────── */
function buildSidebar(id) {
var box = document.getElementById('sidebar-content'); if (!box) return;
var SB = W.SIDEBARS || {}; var sb = SB[id] || SB[(PARAS()[0] || {}).id] || { title: '', rows: [] };
var xpLv = xpForLevel(STATE.level), xpNext = xpForLevel(STATE.level + 1);
var pct = (xpNext - xpLv) > 0 ? Math.round((STATE.xp - xpLv) / (xpNext - xpLv) * 100) : 100;
var html = '<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. ' + STATE.level + '</span></div>'
+ '<div class="xp-bar"><div class="xp-fill" style="width:' + pct + '%"></div></div>'
+ '<div class="xp-nums"><span>' + STATE.xp + ' XP</span><span>' + xpNext + ' XP</span></div></div>';
html += '<div class="sidecard"><h4>' + sb.title + '</h4>';
sb.rows.forEach(function (r) { html += '<div class="sidecard-row"><b>' + r[0] + '</b>' + (r[1] ? ' — ' + r[1] : '') + '</div>'; });
html += '</div>';
var tips = W.TIPS || []; var tip = tips.filter(function (t) { return t.sec === id; })[0] || tips[0];
if (tip) html += '<div class="sidecard tip"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 22 20 2 20"/></svg> Подсказка</h4><div class="sidecard-row" style="font-size:.84rem;line-height:1.55">' + tip.html + '</div></div>';
if (STATE.achievements.size > 0) {
html += '<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">' + STATE.achievements.size + '</span></h4>';
var vals = []; STATE.achievements.forEach(function (v) { vals.push(v); });
vals.slice(-4).forEach(function (t) { html += '<div class="sidecard-row done">✓ ' + t + '</div>'; });
html += '</div>';
}
box.innerHTML = html;
if (W.renderMathInElement) try { renderMath(box); } catch (e) {}
}
/* ── карточки / навигация / кнопка прочтения ───────────────────── */
var ICONS = {
theory: '<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
example: '<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
rule: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
lab: '<svg class="ic" viewBox="0 0 24 24"><path d="M10 2v7.5L4.5 19a2 2 0 0 0 1.7 3h11.6a2 2 0 0 0 1.7-3L14 9.5V2"/><line x1="9" y1="2" x2="15" y2="2"/></svg>'
};
function makeCard(kind, title, num, body) {
var labels = { theory: 'Теория', example: 'Пример', rule: 'Правило', lab: 'Практика' };
return '<div class="card"><div class="card-header"><div class="card-icon ' + kind + '">' + (ICONS[kind] || ICONS.theory) + '</div>'
+ '<div class="card-title">' + (labels[kind] || '') + (title && title !== labels[kind] ? ' \xb7 ' + title : '') + '</div>'
+ (num ? '<div class="card-num">' + num + '</div>' : '') + '</div><div class="card-body">' + body + '</div></div>';
}
function paraName(id) { var p = PARAS().filter(function (x) { return x.id === id; })[0]; return p ? p.num : id; }
function secNav(prev, next) {
var h = '<div class="sec-nav">';
h += prev ? '<button class="btn" onclick="goTo(\'' + prev + '\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> ' + paraName(prev) + '</button>' : '<span></span>';
h += next ? '<button class="btn primary" onclick="goTo(\'' + next + '\')">' + paraName(next) + ' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>' : '<span></span>';
return h + '</div>';
}
function readButton(paraId) {
var p = PARAS().filter(function (x) { return x.id === paraId; })[0];
var tail = p && p.final ? 'финал' : (p ? p.num : '?');
return '<div class="read-wrap"><button class="btn primary" id="' + paraId + '-read-btn">'
+ '<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> Я изучил — ' + tail + ' (+10 XP)</button></div>';
}
function wireReadBtn(paraId) {
var btn = document.getElementById(paraId + '-read-btn'); if (!btn || btn._wired) return; btn._wired = 1;
btn.addEventListener('click', function () {
addXp(10, paraId + '-read'); bumpProgress(paraId, 30);
btn.textContent = 'Изучено! +10 XP'; btn.disabled = true; btn.style.opacity = .6;
var aId = paraId + '_done'; if (ACHL()[aId]) achievement(aId);
});
}
function renderMath(root) {
if (!W.renderMathInElement) return;
try { W.renderMathInElement(root, { delimiters: [{ left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
}
function doRender(el) { renderMath(el); }
/* ── ДВИЖОК ЗАДАЧ ──────────────────────────────────────────────── */
function renderTask(sec) {
var pool = POOLS()[sec], s = SEC[sec];
var area = document.getElementById('taskArea' + sec), fb = document.getElementById('fb' + sec), sum = document.getElementById('sum' + sec);
if (!area || !fb || !sum || !pool || !s) return;
sum.classList.remove('show');
var q = pool[s.idx], done = s.results[s.idx] !== null, isMcq = !!q.opts;
s.answered = done;
if (isMcq) {
var selIdx = s.selections[s.idx];
area.innerHTML = '<div class="task-card"><div class="task-num">Задача ' + (s.idx + 1) + ' из ' + pool.length + ' · Тест</div>'
+ '<div class="task-text">' + q.q + '</div><div class="mcq-opts">'
+ q.opts.map(function (opt, i) {
var cls = 'mcq-opt'; if (done) { if (i === q.a) cls += ' mcq-cor'; else if (i === selIdx) cls += ' mcq-wrong'; }
return '<button class="' + cls + '" id="mcqOpt' + sec + '_' + i + '" onclick="' + (done ? '' : 'selectMcq(\'' + sec + '\',' + i + ')') + '" ' + (done ? 'disabled' : '') + '><span class="mcq-let">' + String.fromCharCode(65 + i) + '.</span>' + opt + '</button>';
}).join('') + '</div></div>';
} else {
area.innerHTML = '<div class="task-card"><div class="task-num">Задача ' + (s.idx + 1) + ' из ' + pool.length + '</div>'
+ '<div class="task-text">' + q.q + '</div>'
+ (q.hint ? '<div class="task-hint"><svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg><span>' + q.hint + '</span></div>' : '')
+ '<div class="ans-row"><label>Ответ:</label><input class="ans-inp" type="text" id="ainp' + sec + '" placeholder="?" autocomplete="off"' + (done ? ' disabled' : '') + '>'
+ '<span class="unit-lbl">' + (q.unit || '') + '</span>'
+ (done ? '' : '<button class="btn primary" onclick="checkNum(\'' + sec + '\')">Проверить</button>') + '</div></div>';
}
if (done) {
var ok = s.results[s.idx];
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
fb.innerHTML = isMcq
? (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.opts[q.a] + '</b>. ' + (q.ex || ''))
: (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.a + ' ' + (q.unit || '') + '</b>. ' + (q.ex || ''));
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
doRender(fb);
} else { fb.className = 'feedback'; var nb2 = document.getElementById('nextBtn' + sec); if (nb2) nb2.style.display = 'none'; }
updateScoreBar(sec); renderNav(sec); doRender(area);
if (!done && !isMcq) {
var inp = document.getElementById('ainp' + sec);
// preventScroll: иначе фокус прокручивает страницу к блоку задач (внизу §)
setTimeout(function () { if (inp) { try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); } } }, 80);
if (inp) inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') checkNum(sec); });
}
}
function selectMcq(sec, i) {
var s = SEC[sec]; if (!s || s.answered) return;
var q = POOLS()[sec][s.idx], ok = i === q.a;
s.results[s.idx] = ok; s.selections[s.idx] = i; s.answered = true;
if (ok) maybeAwardTask(sec);
q.opts.forEach(function (_, j) {
var btn = document.getElementById('mcqOpt' + sec + '_' + j); if (!btn) return;
btn.disabled = true; if (j === q.a) btn.classList.add('mcq-cor'); else if (j === i && !ok) btn.classList.add('mcq-wrong');
});
var fb = document.getElementById('fb' + sec);
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
fb.innerHTML = ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.opts[q.a] + '</b>. ' + (q.ex || '');
doRender(fb);
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
updateScoreBar(sec); renderNav(sec); finishCheck(sec);
}
function checkNum(sec) {
var s = SEC[sec]; if (!s || s.answered) return;
var q = POOLS()[sec][s.idx], inp = document.getElementById('ainp' + sec), fb = document.getElementById('fb' + sec);
var val = (inp.value || '').trim().replace(',', '.'), num = parseFloat(val);
if (!val || isNaN(num)) { fb.className = 'feedback show fb-fail'; fb.innerHTML = 'Введите числовой ответ!'; return; }
s.answered = true;
var tol = q.tol !== undefined ? q.tol : 0.03;
var ok = q.a === 0 ? Math.abs(num) < 0.05 : Math.abs((num - q.a) / q.a) < tol;
s.results[s.idx] = ok; if (ok) maybeAwardTask(sec);
inp.disabled = true; inp.style.borderColor = ok ? 'var(--ok)' : 'var(--fail)';
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
fb.innerHTML = ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.a + ' ' + (q.unit || '') + '</b>. ' + (q.ex || '');
doRender(fb);
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
updateScoreBar(sec); renderNav(sec); finishCheck(sec);
}
function maybeAwardTask(sec) {
var s = SEC[sec]; if (s._awarded === undefined) s._awarded = {};
if (s._awarded[s.idx]) return; s._awarded[s.idx] = 1; addXp(5, sec + '-task');
}
function finishCheck(sec) {
var s = SEC[sec];
if (s.results.every(function (r) { return r !== null; })) setTimeout(function () { showSummary(sec); }, 1600);
}
function nextTask(sec) {
var s = SEC[sec], pool = POOLS()[sec];
var next = -1;
for (var k = 1; k <= pool.length; k++) { var j = (s.idx + k) % pool.length; if (s.results[j] === null) { next = j; break; } }
if (next === -1) { showSummary(sec); return; }
s.idx = next; s.answered = s.results[next] !== null; renderTask(sec);
}
function goToTask(sec, idx) { var s = SEC[sec]; s.idx = idx; s.answered = s.results[idx] !== null; renderTask(sec); }
function resetTasks(sec) {
var pool = POOLS()[sec];
SEC[sec] = { idx: 0, results: pool.map(function () { return null; }), selections: pool.map(function () { return null; }), answered: false, _awarded: {} };
var sum = document.getElementById('sum' + sec); if (sum) sum.classList.remove('show');
renderTask(sec);
}
function renderNav(sec) {
var s = SEC[sec], pool = POOLS()[sec], nd = document.getElementById('navDots' + sec); if (!nd) return;
nd.innerHTML = pool.map(function (_, i) {
var cls = 'nav-dot'; if (i === s.idx) cls += ' nd-cur'; if (s.results[i] === true) cls += ' nd-ok'; else if (s.results[i] === false) cls += ' nd-fail';
return '<button class="' + cls + '" onclick="goToTask(\'' + sec + '\',' + i + ')">' + (i + 1) + '</button>';
}).join('');
}
function updateScoreBar(sec) {
var s = SEC[sec], pool = POOLS()[sec];
var ok = s.results.filter(function (r) { return r === true; }).length;
var ans = s.results.filter(function (r) { return r !== null; }).length;
setTxt('ok' + sec, ok); setTxt('cur' + sec, ans); setTxt('max' + sec, pool.length);
var pf = document.getElementById('prog' + sec); if (pf) pf.style.width = Math.round(ans / pool.length * 100) + '%';
}
function showSummary(sec) {
var s = SEC[sec], pool = POOLS()[sec], sum = document.getElementById('sum' + sec); if (!sum) return;
var ok = s.results.filter(function (r) { return r === true; }).length;
setTxt('sumScore' + sec, ok + ' / ' + pool.length);
var grade = ok === pool.length ? 'Отлично! Все задачи решены.' : ok >= pool.length * 0.6 ? 'Хорошо! Можно повторить ошибки.' : 'Стоит повторить параграф.';
setTxt('sumGrade' + sec, grade);
sum.classList.add('show');
if (ok === pool.length) { bumpProgress(sec, 60); var aId = sec + '_tasks'; if (ACHL()[aId]) achievement(aId); }
}
function setTxt(id, v) { var e = document.getElementById(id); if (e) e.textContent = v; }
/* ── тема ──────────────────────────────────────────────────────── */
function initTheme() {
var t = localStorage.getItem(K.theme) || localStorage.getItem('theme') || 'light';
if (t === 'dark') document.documentElement.classList.add('dark');
var lab = document.getElementById('theme-lab'); if (lab) lab.textContent = t === 'dark' ? 'Светлая' : 'Тёмная';
var btn = document.getElementById('theme-btn'); if (!btn) return;
btn.addEventListener('click', function () {
document.documentElement.classList.toggle('dark');
var d = document.documentElement.classList.contains('dark');
localStorage.setItem(K.theme, d ? 'dark' : 'light'); localStorage.setItem('theme', d ? 'dark' : 'light');
if (lab) lab.textContent = d ? 'Светлая' : 'Тёмная';
});
}
/* ── init ──────────────────────────────────────────────────────── */
function init() {
resolveCfg();
loadProgress(); initTheme(); buildParaSelector(); refreshUI();
if (ACHL().start) achievement('start');
var first = (PARAS()[0] || {}).id; if (first) goTo(first);
refreshUI(); loadServerReadState();
W.addEventListener('focus', loadServerReadState);
}
/* экспорт */
W.goTo = goTo; W.ensureBuilt = ensureBuilt;
W.checkNum = checkNum; W.selectMcq = selectMcq; W.nextTask = nextTask; W.goToTask = goToTask; W.resetTasks = resetTasks;
W.renderTask = renderTask;
W.makeCard = makeCard; W.secNav = secNav; W.readButton = readButton; W.wireReadBtn = wireReadBtn;
W.addXp = addXp; W.achievement = achievement; W.bumpProgress = bumpProgress; W.chem8RenderMath = renderMath;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})(window);
+183
View File
@@ -0,0 +1,183 @@
/* chem8_glossary.js — глоссарий учебника «Химия 8».
* Самодостаточный drop-in: словарь терминов + плавающая кнопка + модалка с поиском
* + авто-подсветка терминов в .card-body (tooltip с определением). Стили инжектятся.
* Подключается одним тегом <script src="/js/chem8_glossary.js" defer></script>.
*/
(function (W) {
'use strict';
var D = W.document;
/* словарь: термин → {d: определение, see: [связанные]} */
var G = {
'атом': { d: 'Мельчайшая химически неделимая частица вещества: ядро (протоны и нейтроны) + электроны.', see: ['химический элемент', 'нуклид'] },
'химический элемент': { d: 'Вид атомов с одинаковым зарядом ядра (числом протонов).', see: ['атом'] },
'относительная атомная масса': { d: 'Безразмерная величина $A_r$ — во сколько раз масса атома больше 1/12 массы атома углерода-12.', see: ['относительная молекулярная масса'] },
'относительная молекулярная масса': { d: 'Сумма относительных атомных масс всех атомов в формуле ($M_r$).', see: ['молярная масса'] },
'простое вещество': { d: 'Вещество из атомов одного элемента (O₂, Fe).', see: ['сложное вещество'] },
'сложное вещество': { d: 'Вещество из атомов разных элементов (H₂O, CaCO₃).', see: ['простое вещество'] },
'химическая формула': { d: 'Запись состава вещества символами элементов с индексами.', see: [] },
'химическое количество': { d: 'Физическая величина $n$ (порция вещества), измеряется в молях.', see: ['моль', 'постоянная Авогадро'] },
'моль': { d: 'Единица химического количества: содержит $6{,}02\\cdot10^{23}$ частиц (число Авогадро).', see: ['постоянная Авогадро'] },
'постоянная Авогадро': { d: '$N_A = 6{,}02\\cdot10^{23}$ частиц/моль — число частиц в 1 моль.', see: ['моль'] },
'молярная масса': { d: 'Масса 1 моль вещества $M$ (г/моль); численно равна $M_r$.', see: ['относительная молекулярная масса'] },
'молярный объём': { d: 'Объём 1 моль газа; при н.у. $V_m = 22{,}4$ л/моль.', see: [] },
'оксид': { d: 'Сложное вещество из элемента и кислорода (с.о. −2): основный, кислотный, амфотерный, несолеобразующий.', see: ['основный оксид', 'кислотный оксид'] },
'основный оксид': { d: 'Оксид металла, реагирует с кислотами (CaO, Na₂O).', see: ['оксид'] },
'кислотный оксид': { d: 'Оксид неметалла, реагирует со щелочами (CO₂, SO₃).', see: ['оксид'] },
'амфотерность': { d: 'Способность вещества проявлять и кислотные, и основные свойства (Zn(OH)₂, Al(OH)₃).', see: ['оксид', 'основание'] },
'кислота': { d: 'Вещество с атомами водорода, способными замещаться металлом, и кислотным остатком.', see: ['основность'] },
'основность': { d: 'Число атомов водорода в кислоте, способных замещаться металлом.', see: ['кислота'] },
'основание': { d: 'Вещество из металла и гидроксогрупп OH; растворимые — щёлочи.', see: ['щёлочь', 'нейтрализация'] },
'щёлочь': { d: 'Растворимое в воде основание (NaOH, KOH, Ba(OH)₂).', see: ['основание'] },
'соль': { d: 'Вещество из катионов металла и анионов кислотного остатка (NaCl, CaCO₃).', see: ['реакция ионного обмена'] },
'нейтрализация': { d: 'Реакция кислоты с основанием: соль + вода.', see: ['кислота', 'основание'] },
'индикатор': { d: 'Вещество, меняющее окраску в зависимости от среды (лакмус, фенолфталеин, метилоранж).', see: [] },
'реакция ионного обмена': { d: 'Реакция между растворами, идущая до конца при образовании осадка ↓, газа ↑ или воды.', see: ['соль', 'растворимость'] },
'ряд активности металлов': { d: 'Ряд металлов по убыванию химической активности; металл вытесняет менее активные.', see: [] },
'генетическая связь': { d: 'Связь между классами веществ через цепочки превращений (металл→оксид→основание→соль).', see: [] },
'периодический закон': { d: 'Свойства элементов периодически зависят от заряда ядра их атомов (Д. И. Менделеев, 1869).', see: ['периодическая система'] },
'периодическая система': { d: 'Таблица элементов: периоды (строки) и группы (столбцы).', see: ['период', 'группа'] },
'период': { d: 'Горизонтальный ряд в ПСХЭ; номер = число электронных слоёв.', see: ['периодическая система'] },
'группа': { d: 'Вертикальный столбец ПСХЭ; номер = число внешних электронов.', see: ['периодическая система'] },
'нуклид': { d: 'Вид атомов с определёнными Z (протоны) и N (нейтроны).', see: ['изотопы', 'массовое число'] },
'массовое число': { d: 'Число протонов и нейтронов в ядре: $A = Z + N$.', see: ['нуклид'] },
'изотопы': { d: 'Атомы одного элемента с разным числом нейтронов (одинаковый Z, разный A).', see: ['нуклид'] },
'электронное облако': { d: 'Область вокруг ядра, где электрон бывает чаще всего.', see: ['орбиталь'] },
'орбиталь': { d: 'Форма электронного облака: s — сфера, p — гантель.', see: ['электронное облако'] },
'электроотрицательность': { d: 'Способность атома притягивать к себе общие электроны.', see: ['ковалентная связь'] },
'ковалентная связь': { d: 'Связь за счёт общих электронных пар (между неметаллами).', see: ['электроотрицательность', 'ионная связь'] },
'ионная связь': { d: 'Связь за счёт полной передачи электронов от металла к неметаллу; образуются ионы.', see: ['ковалентная связь'] },
'металлическая связь': { d: 'Связь ион-остовов металла «электронным газом» из общих электронов.', see: [] },
'кристаллическая решётка': { d: 'Упорядоченное расположение частиц в кристалле: ионная, атомная, молекулярная, металлическая.', see: [] },
'степень окисления': { d: 'Условный заряд атома в соединении (H +1, O 2, сумма = 0).', see: ['окисление', 'восстановление'] },
'окисление': { d: 'Процесс отдачи электронов (степень окисления повышается).', see: ['восстановление', 'степень окисления'] },
'восстановление': { d: 'Процесс приёма электронов (степень окисления понижается).', see: ['окисление'] },
'окислитель': { d: 'Частица, принимающая электроны (сама восстанавливается).', see: ['восстановитель'] },
'восстановитель': { d: 'Частица, отдающая электроны (сама окисляется).', see: ['окислитель'] },
'окислительно-восстановительная реакция': { d: 'Реакция с изменением степеней окисления (переход электронов).', see: ['степень окисления'] },
'смесь': { d: 'Несколько веществ вместе: однородная (раствор) или неоднородная.', see: ['раствор'] },
'раствор': { d: 'Однородная смесь растворителя и растворённого вещества.', see: ['растворимость', 'массовая доля'] },
'растворимость': { d: 'Масса вещества, растворяющаяся в 100 г воды при данной температуре.', see: ['раствор'] },
'насыщенный раствор': { d: 'Раствор, в котором вещество больше не растворяется при данной температуре.', see: ['раствор'] },
'массовая доля': { d: 'Отношение массы растворённого вещества к массе раствора: $w = m_{в-ва}/m_{р-ра}$.', see: ['раствор'] },
'молярная концентрация': { d: 'Химическое количество вещества в 1 л раствора: $c = n/V$ (моль/л).', see: ['раствор'] }
};
var TERMS = Object.keys(G).sort(function (a, b) { return b.length - a.length; }); // длинные раньше
function injectCSS() {
if (D.getElementById('chem8-gloss-css')) return;
var s = D.createElement('style'); s.id = 'chem8-gloss-css';
s.textContent =
'.gloss{border-bottom:1.5px dotted var(--pri,#d97706);cursor:help;text-decoration:none}'
+ '.gl-fab{position:fixed;left:16px;bottom:16px;z-index:55;display:inline-flex;align-items:center;gap:7px;padding:9px 14px;border:none;border-radius:99px;background:var(--pri,#d97706);color:#fff;font-weight:700;font-size:.84rem;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}'
+ '.gl-fab:hover{filter:brightness(1.08)}.gl-fab svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}'
+ '.gl-modal{position:fixed;inset:0;z-index:80;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto}'
+ '.gl-modal.show{display:flex}'
+ '.gl-box{background:var(--card,#fff);color:var(--text,#1c1917);border-radius:16px;max-width:600px;width:100%;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.3)}'
+ '.gl-h{display:flex;align-items:center;gap:10px;margin-bottom:12px}.gl-h h3{font-family:Outfit,sans-serif;font-size:1.15rem;font-weight:800;flex:1}'
+ '.gl-close{border:none;background:transparent;font-size:1.4rem;cursor:pointer;color:var(--muted,#888);line-height:1}'
+ '.gl-search{width:100%;padding:10px 13px;border:1.5px solid var(--border,#ddd);border-radius:10px;background:var(--card,#fff);color:var(--text,#1c1917);font-family:inherit;font-size:.95rem;margin-bottom:12px}'
+ '.gl-list{max-height:60vh;overflow:auto}'
+ '.gl-item{padding:10px 12px;border-bottom:1px solid var(--border,#eee)}.gl-item:last-child{border-bottom:0}'
+ '.gl-term{font-weight:800;color:var(--pri-d,#b45309);text-transform:capitalize}'
+ '.gl-def{font-size:.9rem;margin-top:3px;line-height:1.5}'
+ '.gl-see{font-size:.8rem;color:var(--muted,#888);margin-top:4px}'
+ '.gl-pop{position:absolute;z-index:90;max-width:280px;background:var(--card,#fff);color:var(--text,#1c1917);border:1.5px solid var(--pri,#d97706);border-radius:10px;padding:10px 13px;font-size:.86rem;line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,.2);display:none}'
+ '.gl-pop.show{display:block}.gl-pop b{color:var(--pri-d,#b45309);text-transform:capitalize}';
D.head.appendChild(s);
}
function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/* авто-подсветка терминов в .card-body (первое вхождение каждого, в текстовых узлах) */
function decorate(root) {
if (!root) return;
var bodies = root.matches && root.matches('.card-body') ? [root] : root.querySelectorAll ? root.querySelectorAll('.card-body') : [];
Array.prototype.forEach.call(bodies, function (body) {
if (body._glossed) return; body._glossed = 1;
var used = {};
TERMS.forEach(function (term) {
if (used[term]) return;
var walker = D.createTreeWalker(body, W.NodeFilter.SHOW_TEXT, null);
var node, re = new RegExp('(^|[^а-яёА-ЯЁ-])(' + esc(term) + ')(?![а-яёА-ЯЁ])', 'i');
while ((node = walker.nextNode())) {
if (node.parentNode && (node.parentNode.classList && (node.parentNode.classList.contains('gloss') || node.parentNode.closest('.gloss,abbr,a,.ph-formula,.main-f,code')))) continue;
var m = node.nodeValue.match(re);
if (m) {
var idx = m.index + m[1].length;
var before = node.nodeValue.slice(0, idx), word = node.nodeValue.slice(idx, idx + term.length), after = node.nodeValue.slice(idx + term.length);
var ab = D.createElement('abbr'); ab.className = 'gloss'; ab.setAttribute('data-term', term.toLowerCase()); ab.textContent = word;
var frag = D.createDocumentFragment();
frag.appendChild(D.createTextNode(before)); frag.appendChild(ab); frag.appendChild(D.createTextNode(after));
node.parentNode.replaceChild(frag, node);
used[term] = 1; break;
}
}
});
});
}
/* popover при наведении/клике на .gloss */
var pop;
function showPop(ab) {
var term = ab.getAttribute('data-term'); var g = G[term]; if (!g) return;
if (!pop) { pop = D.createElement('div'); pop.className = 'gl-pop'; D.body.appendChild(pop); }
pop.innerHTML = '<b>' + term + '</b><br>' + g.d + (g.see && g.see.length ? '<div class="gl-see">См.: ' + g.see.join(', ') + '</div>' : '');
var r = ab.getBoundingClientRect();
pop.style.left = Math.min(r.left, W.innerWidth - 300) + 'px';
pop.style.top = (r.bottom + W.scrollY + 6) + 'px';
pop.classList.add('show');
renderMath(pop);
}
function hidePop() { if (pop) pop.classList.remove('show'); }
function renderMath(el) { if (typeof W.renderMathInElement === 'function') { try { W.renderMathInElement(el, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} } }
/* модалка */
var modal;
function buildModal() {
modal = D.createElement('div'); modal.className = 'gl-modal';
modal.innerHTML = '<div class="gl-box"><div class="gl-h"><h3>Глоссарий — Химия 8</h3><button class="gl-close" aria-label="Закрыть">&times;</button></div>'
+ '<input class="gl-search" placeholder="Поиск термина...">'
+ '<div class="gl-list"></div></div>';
D.body.appendChild(modal);
var list = modal.querySelector('.gl-list'), search = modal.querySelector('.gl-search');
function render(q) {
q = (q || '').toLowerCase().trim();
var keys = Object.keys(G).sort();
list.innerHTML = keys.filter(function (t) { return !q || t.indexOf(q) >= 0 || G[t].d.toLowerCase().indexOf(q) >= 0; })
.map(function (t) { return '<div class="gl-item"><div class="gl-term">' + t + '</div><div class="gl-def">' + G[t].d + '</div>' + (G[t].see && G[t].see.length ? '<div class="gl-see">См.: ' + G[t].see.join(', ') + '</div>' : '') + '</div>'; }).join('') || '<div class="gl-item">Ничего не найдено.</div>';
renderMath(list);
}
search.addEventListener('input', function () { render(search.value); });
modal.querySelector('.gl-close').addEventListener('click', close);
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
render('');
}
function open() { if (!modal) buildModal(); modal.classList.add('show'); var s = modal.querySelector('.gl-search'); if (s) setTimeout(function () { s.focus(); }, 50); }
function close() { if (modal) modal.classList.remove('show'); }
function init() {
injectCSS();
var fab = D.createElement('button'); fab.className = 'gl-fab';
fab.innerHTML = '<svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> Глоссарий';
fab.addEventListener('click', open);
D.body.appendChild(fab);
D.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// авто-подсветка терминов: при наведении/клике — popover
D.body.addEventListener('mouseover', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) showPop(e.target); });
D.body.addEventListener('mouseout', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) hidePop(); });
D.body.addEventListener('click', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) { e.preventDefault(); showPop(e.target); } });
// первичная декорация + наблюдение за лениво строящимися §
decorate(D.body);
try {
var obs = new W.MutationObserver(function (muts) {
muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes, function (n) { if (n.nodeType === 1) decorate(n); }); });
});
obs.observe(D.body, { childList: true, subtree: true });
} catch (e) {}
}
W.Chem8Glossary = { open: open, decorate: decorate, terms: G };
if (D.readyState === 'loading') D.addEventListener('DOMContentLoaded', init); else init();
})(window);
+146
View File
@@ -0,0 +1,146 @@
/* chem8_intro_widgets.js — виджеты вводного раздела «Химия 8».
* Монтируются движком chem8_engine.js: window.CHEM8_WIDGETS[id] / window.FLAG_MOUNTS[id].
* Используют window.Chem8 (chem8_svg.js): molarMass, elementCounts, arOf, fmt,
* moleTriangle, equationBalancer.
*/
(function (W) {
'use strict';
function C() { return W.Chem8 || {}; }
function $(id) { return document.getElementById(id); }
function rr(v, d) { var p = Math.pow(10, d == null ? 3 : d); return (Math.round(v * p) / p).toString().replace('.', ','); }
/* §1 — карта элементов */
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 mount_p1() {
var grid = $('p1-el'), info = $('p1-elinfo'); if (!grid || grid._built) return; grid._built = 1;
Object.keys(EL).forEach(function (s) {
var ar = C().arOf ? C().arOf(s) : '';
var c = document.createElement('div'); c.className = 'el-cell';
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);
});
}
/* §2 — калькулятор Mr */
function mount_p2() {
var inp = $('p2-mr-in'), out = $('p2-mr-out'), go = $('p2-mr-go'); if (!inp || inp._built) return; inp._built = 1;
function calc() {
var f = inp.value.trim(), cnt = C().elementCounts ? C().elementCounts(f) : null, mr = C().molarMass ? C().molarMass(f) : NaN;
if (!cnt || isNaN(mr)) { out.className = 'out bad'; out.textContent = 'Не удалось разобрать формулу. Проверьте символы элементов.'; return; }
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>Σ = ' + Object.keys(cnt).map(function (e) { return (C().arOf ? C().arOf(e) : '?') + '·' + cnt[e]; }).join(' + ') + ' = ' + C().fmt(mr) + '</span>';
}
go.addEventListener('click', calc);
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); });
document.querySelectorAll('.p2-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); });
calc();
}
/* §3 — порция вещества */
function mount_p3() {
var sub = $('p3-sub'), rng = $('p3-n'), nv = $('p3-nv'), out = $('p3-out'); if (!sub || sub._built) return; sub._built = 1;
var M = { H2O: 18, O2: 32, CO2: 44, NaCl: 58.5 };
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, 1) + ' г</b><br>N = n·N_A = <b>' + rr(N, 2) + '·10²³ частиц</b></span>';
}
sub.addEventListener('change', upd); rng.addEventListener('input', upd); upd();
}
/* §4 — счётчик частиц */
function mount_p4() {
var rng = $('p4-n'), nv = $('p4-nv'), out = $('p4-out'); if (!rng || rng._built) return; rng._built = 1;
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>' + rr(N, 2) + '·10²³ частиц</b></span>'; }
rng.addEventListener('input', upd); upd();
}
/* §5 — M + объём газа */
function mount_p5() {
var inp = $('p5-in'), out = $('p5-out'), go = $('p5-go'); if (!inp || inp._built) return; inp._built = 1;
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 = ' + rr(mr / 22.4) + ' г/л</span>';
}
go.addEventListener('click', calc); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc();
}
/* §6 / ПР1 — треугольник n–m–M (флагман) */
function mount_triangle(mountId, subId) {
var mount = $(mountId), sub = $(subId); if (!mount || mount._built || !C().moleTriangle) return; mount._built = 1;
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);
});
}
function mount_p6() { mount_triangle('p6-mount', 'p6-sub'); }
function mount_pr1() { mount_triangle('pr1-mount', 'pr1-sub'); }
/* §7 — универсальный калькулятор газа (флагман) */
function mount_p7() {
var sub = $('p7-sub'), key = $('p7-key'), val = $('p7-val'), go = $('p7-go'), out = $('p7-out'); if (!sub || sub._built) return; sub._built = 1;
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;
out.className = 'out ok';
out.innerHTML = '<span class="bd">M(' + f + ')=' + M + ' г/моль<br>n = <b>' + rr(n) + ' моль</b><br>m = <b>' + rr(m) + ' г</b><br>V(н.у.) = <b>' + rr(V) + ' л</b><br>N = <b>' + rr(N) + '·10²³ частиц</b></span>';
}
go.addEventListener('click', calc); val.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc();
}
/* §8 — балансировщик (флагман) */
function mount_p8() {
var pick = $('p8-pick'), mount = $('p8-mount'); if (!pick || pick._built || !C().equationBalancer) return; pick._built = 1;
function build() { var parts = pick.value.split('|'); C().equationBalancer(mount, { skeleton: parts[0], solution: parts[1].split(',').map(Number) }); }
pick.addEventListener('change', build); build();
}
/* §9 — пошаговый решатель (флагман) */
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 mount_p9() {
var pick = $('p9-pick'), out = $('p9-out'), bStep = $('p9-step'), bAll = $('p9-all'); if (!pick || pick._built) return; pick._built = 1;
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="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
if (shown === 0) html += '<span style="color:var(--muted)">Нажмите «Следующий шаг», чтобы решать пошагово.</span>';
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
if (W.chem8RenderMath) try { W.chem8RenderMath(out); } catch (e) {}
}
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();
}
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"n","t":"n, моль","x":170,"y":55,"c":"#d97706"},{"id":"m","t":"m, г","x":20,"y":22},{"id":"M","t":"M, г/моль","x":20,"y":95},{"id":"V","t":"V, л","x":330,"y":22},{"id":"N","t":"N частиц","x":330,"y":95}],"edges":[{"f":"m","t":"n","label":"n = m / M"},{"f":"M","t":"n","label":"M = m / n"},{"f":"n","t":"V","label":"V = n · 22,4 (газ, н.у.)"},{"f":"n","t":"N","label":"N = n · 6,02·10²³"}]}); } }
W.CHEM8_WIDGETS = { p1: mount_p1, p2: mount_p2, p3: mount_p3, p4: mount_p4, p5: mount_p5, pr1: mount_pr1 };
W.FLAG_MOUNTS = { final1: mount_final1, p6: mount_p6, p7: mount_p7, p8: mount_p8, p9: mount_p9 };
})(window);
+132
View File
@@ -0,0 +1,132 @@
/* chem8_mol.js — 3D-модели молекул и кристаллических решёток (U4).
* Поверх biochem-core (window.BIO): vsepr + render3D. Вращение мышью/пальцем
* (window-listeners, без setPointerCapture). Экспорт: window.Chem8Mol.
*/
(function (W) {
'use strict';
var D = W.document;
function BIO() { return W.BIO; }
function C() { return W.Chem8 || {}; }
/* предопределённые молекулы: atoms + bonds */
var MOL = {
H2: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Водород H₂' },
Cl2: { atoms: [{ id: 1, s: 'Cl' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлор Cl₂' },
O2: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }], name: 'Кислород O₂' },
N2: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'N' }], bonds: [{ f: 1, t: 2, o: 3 }], name: 'Азот N₂' },
HCl: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлороводород HCl' },
H2O: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }], name: 'Вода H₂O' },
CO2: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'O' }, { id: 3, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }, { f: 1, t: 3, o: 2 }], name: 'Углекислый газ CO₂' },
NH3: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }], name: 'Аммиак NH₃' },
CH4: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }, { id: 5, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }, { f: 1, t: 5, o: 1 }], name: 'Метан CH₄' }
};
function mkCanvas(host, h) {
var cv = D.createElement('canvas'); cv.className = 'mol-cv';
cv.style.width = '100%'; cv.style.height = (h || 200) + 'px'; cv.style.touchAction = 'none';
cv.style.borderRadius = '12px'; cv.style.display = 'block';
host.appendChild(cv); return cv;
}
function fit(cv) {
var dpr = W.devicePixelRatio || 1, w = cv.offsetWidth || 280, h = cv.offsetHeight || 200;
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
var ctx = cv.getContext && cv.getContext('2d'); if (!ctx) return null; // jsdom без canvas
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, W: w, H: h };
}
/* общий движок вращения: state выше redraw, window-listeners */
function attachRotate(cv, state, redraw) {
var dragging = false, lx = 0, ly = 0;
cv.addEventListener('pointerdown', function (e) { dragging = true; lx = e.clientX; ly = e.clientY; state.spin = false; });
W.addEventListener('pointermove', function (e) {
if (!dragging) return;
state.rotY += (e.clientX - lx) * 0.01; state.rotX += (e.clientY - ly) * 0.01;
lx = e.clientX; ly = e.clientY; redraw();
});
W.addEventListener('pointerup', function () { dragging = false; });
}
/* ── 3D-модель молекулы ── */
function molModel(mount, key) {
var host = typeof mount === 'string' ? D.querySelector(mount) : mount;
if (!host || !BIO()) return null;
var keys = Object.keys(MOL);
host.innerHTML = '<div class="fld"><label>Молекула</label><select class="mol-sel">' +
keys.map(function (k) { return '<option value="' + k + '"' + (k === key ? ' selected' : '') + '>' + MOL[k].name + '</option>'; }).join('') + '</select>'
+ '<button class="btn mol-spin">⟳ Вращение</button></div>';
var stage = D.createElement('div'); host.appendChild(stage);
var cv = mkCanvas(stage, 200);
var info = D.createElement('div'); info.className = 'out mol-info'; host.appendChild(info);
var sel = host.querySelector('.mol-sel'), spinBtn = host.querySelector('.mol-spin');
var state = { rotX: -0.35, rotY: 0.6, scale: 2.6, spin: true };
var cur;
function load(k) {
cur = MOL[k]; var g = BIO().vsepr(cur.atoms, cur.bonds); cur.g = g;
var pol = BIO().polarity(cur.atoms, cur.bonds);
var mr = C().molarMass ? C().molarMass(k) : BIO().molarMass(cur.atoms);
var bondTxt = cur.atoms.length === 2 && C().bondClass
? C().bondClass(cur.atoms[0].s, cur.atoms[1].s).type
: (pol.label === 'Ионная' ? 'ионная' : 'ковалентная');
info.className = 'out mol-info ok';
info.innerHTML = '<span class="bd"><b>' + cur.name + '</b> · M = ' + (C().fmt ? C().fmt(mr) : mr) + ' г/моль<br>'
+ 'Связь: ' + bondTxt + ' · молекула: <b>' + pol.label.toLowerCase() + '</b>'
+ (g.shape ? ' · форма: ' + g.shape : '') + '</span>';
}
function redraw() {
var d = fit(cv); if (!d) return;
BIO().render3D(d.ctx, cur.g.atoms3d, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' });
}
sel.addEventListener('change', function () { load(sel.value); redraw(); });
spinBtn.addEventListener('click', function () { state.spin = !state.spin; spinBtn.classList.toggle('primary', state.spin); });
attachRotate(cv, state, redraw);
load(key && MOL[key] ? key : keys[0]);
redraw();
if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.012; redraw(); } W.requestAnimationFrame(loop); })(); // не стартуем цикл без canvas-контекста (jsdom)
return { el: host };
}
/* ── кристаллические решётки (§41) ── */
var LAT = {
ionic: { name: 'Ионная (NaCl)', build: function () { return cube(['Na', 'Cl']); }, note: 'Узлы — ионы Na⁺ и Cl⁻. Прочная решётка → тугоплавкие, твёрдые вещества.' },
atomic: { name: 'Атомная (алмаз)', build: function () { return cube(['C', 'C']); }, note: 'Узлы — атомы, связанные ковалентно. Очень твёрдые, тугоплавкие.' },
molecular: { name: 'Молекулярная (лёд)', build: function () { return cube(['O', 'O']); }, note: 'Узлы — молекулы со слабым притяжением. Летучие, легкоплавкие.' },
metallic: { name: 'Металлическая (Fe)', build: function () { return cube(['Fe', 'Fe'], true); }, note: 'Ион-остовы металла в «электронном газе». Ковкие, проводят ток.' }
};
function cube(symPair, electrons) {
var L = 16, atoms = [], id = 1;
for (var xi = -1; xi <= 1; xi += 2) for (var yi = -1; yi <= 1; yi += 2) for (var zi = -1; zi <= 1; zi += 2) {
var parity = ((xi + yi + zi) / 2 + 3) % 2;
atoms.push({ id: id++, s: symPair[parity], x: xi * L, y: yi * L, z: zi * L });
}
var bonds = [];
for (var i = 0; i < atoms.length; i++) for (var j = i + 1; j < atoms.length; j++) {
var a = atoms[i], b = atoms[j], dd = Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.z - b.z);
if (dd === 2 * L) bonds.push({ f: a.id, t: b.id, o: 1 });
}
if (electrons) for (var e = 0; e < 6; e++) atoms.push({ id: id++, s: 'H', x: (e % 3 - 1) * L, y: ((e / 3 | 0) * 2 - 1) * L * 0.5, z: 0 }); // «электроны» как мелкие точки (H — мелкий радиус)
return { atoms: atoms, bonds: bonds };
}
function latticeViewer(mount, type) {
var host = typeof mount === 'string' ? D.querySelector(mount) : mount;
if (!host || !BIO()) return null;
var keys = Object.keys(LAT);
host.innerHTML = '<div class="fld"><label>Тип решётки</label><select class="lat-sel">' +
keys.map(function (k) { return '<option value="' + k + '"' + (k === type ? ' selected' : '') + '>' + LAT[k].name + '</option>'; }).join('') + '</select></div>';
var stage = D.createElement('div'); host.appendChild(stage);
var cv = mkCanvas(stage, 200);
var info = D.createElement('div'); info.className = 'out'; host.appendChild(info);
var sel = host.querySelector('.lat-sel');
var state = { rotX: -0.4, rotY: 0.5, scale: 2.4, spin: true };
var cur;
function load(k) { var l = LAT[k]; cur = l.build(); info.className = 'out ok'; info.innerHTML = '<span class="bd"><b>' + l.name + '</b><br>' + l.note + '</span>'; }
function redraw() { var d = fit(cv); if (!d) return; BIO().render3D(d.ctx, cur.atoms, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' }); }
sel.addEventListener('change', function () { load(sel.value); redraw(); });
attachRotate(cv, state, redraw);
load(type && LAT[type] ? type : keys[0]); redraw();
if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.01; redraw(); } W.requestAnimationFrame(loop); })();
return { el: host };
}
W.Chem8Mol = { molModel: molModel, latticeViewer: latticeViewer, MOL: MOL };
})(window);
+980
View File
@@ -0,0 +1,980 @@
/* 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);
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/*
* LabLoader — ленивый загрузчик кода симуляций (контент-движок, Фаза 3).
*
* Тяжёлый код симуляций (~2.5 МБ) и three.js (~600 КБ) больше НЕ грузятся на старте.
* При открытии симуляции LabLoader.ensure(id) подгружает её файлы (по манифесту
* window.SIM_DEPS из _sim_deps.js) и, при необходимости, three.js — затем резолвит.
*
* Гарантия корректности (самовосстановление): если после загрузки указанных файлов
* глобальная open-функция (SIM_DEPS[id].open, напр. "_openPendulum") всё ещё не
* определена, грузятся ВСЕ ленивые файлы (window.LAB_LAZY_FILES). Поэтому ошибка в
* манифесте не может «сломать» симуляцию — в худшем случае грузится больше файлов
* (поведение как до Фазы 3). Манифест лишь оптимизирует объём загрузки.
*
* Все загрузки кешируются (по URL) и дедуплицируются.
*/
(function () {
var BASE = '/js/labs/';
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js';
var _cache = {}; // url -> Promise
var _allLoaded = false;
function loadScript(url) {
if (_cache[url]) return _cache[url];
_cache[url] = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.async = false; // сохранить порядок при добавлении нескольких сразу
s.onload = function () { resolve(url); };
s.onerror = function () { delete _cache[url]; reject(new Error('LabLoader: не удалось загрузить ' + url)); };
document.head.appendChild(s);
});
return _cache[url];
}
function ensureThree() {
if (typeof window.THREE !== 'undefined') return Promise.resolve();
return loadScript(THREE_URL);
}
function loadFiles(files) {
return Promise.all((files || []).map(function (f) { return loadScript(BASE + f); }));
}
function loadAllLazy() {
if (_allLoaded) return Promise.resolve();
var list = window.LAB_LAZY_FILES || [];
return loadFiles(list).then(function () { _allLoaded = true; });
}
// ensure(id): загрузить всё необходимое для симуляции id, вернуть Promise.
function ensure(id) {
var dep = (window.SIM_DEPS && window.SIM_DEPS[id]) || null;
if (!dep) {
// нет манифеста для id — безопасно грузим всё
return loadAllLazy();
}
var p = dep.three ? ensureThree() : Promise.resolve();
return p
.then(function () { return loadFiles(dep.files); })
.then(function () {
var openName = dep.open;
if (openName && typeof window[openName] !== 'function') {
if (window.console) console.warn('[LabLoader] самовосстановление для "' + id + '": ' + openName + ' не найдена после загрузки ' + JSON.stringify(dep.files) + ' — гружу все ленивые файлы');
return loadAllLazy();
}
});
}
window.LabLoader = {
ensure: ensure,
ensureThree: ensureThree,
loadScript: loadScript,
loadAllLazy: loadAllLazy
};
})();
+109
View File
@@ -0,0 +1,109 @@
'use strict';
/*
* Контент-движок, Фаза 1 — data-driven регистрация ВСЕХ симуляций в LabRegistry.
*
* Вместо ручного переписывания 40 манифестов модуль строит их из единых источников:
* - метаданные (id/cat/title/desc) и preview — из массива SIMS (lab-glue.js);
* - теория — из объекта THEORY (lab-init.js);
* - поведение open(ctx) — из карты OPEN ниже (обёртки над глобальными _openXxx).
* Это структурно гарантирует паритет с прежним каталогом и диспетчеризацией.
*
* Подключается ПОСЛЕДНИМ среди labs-скриптов (defer), поэтому SIMS, THEORY и все
* _openXxx уже определены. Останов/закрытие симуляций по-прежнему выполняет
* «дробовик» _pauseAllSims()/closeSim() (точный паритет) — поэтому stop/destroy
* в манифестах не задаются на этом этапе.
*
* После регистрации if-цепочка в openSim() становится мёртвой и удалена.
*
* В Фазе 1 заменил пилотный _pilots.js. SIMS/THEORY остаются источниками данных
* (SIMS → БД в Фазе 4, THEORY сворачивается в манифесты позже).
*/
(function () {
if (!window.LabRegistry) return;
if (typeof SIMS === 'undefined') return;
var R = window.LabRegistry;
var T = (typeof THEORY !== 'undefined') ? THEORY : {};
// id -> open(ctx). ctx.arg — параметр deep-link (после двоеточия): stereo:cube и т.п.
var OPEN = {
graph: function (c) { _openGraph(); },
projectile: function (c) { _openProjectile(); },
collision: function (c) { _openCollision(); },
triangle: function (c) { _openTriangle(); },
trigcircle: function (c) { _openTrigCircle(); },
emfield: function (c) { _openEMField(c.arg || 'E'); },
molphys: function (c) { _openMolPhys(c.arg); },
circuit: function (c) { _openCircuit(); },
chemistry: function (c) { _openChemistry(c.arg); },
dynamics: function (c) { _openDynamics(c.arg); },
crystal: function (c) { _openCrystal(); },
orbitals: function (c) { _openOrbitals(); },
stereo: function (c) { _openStereo(c.arg); },
chemsandbox: function (c) { _openChemSandbox(); },
celldivision: function (c) { _openCellDivision(); },
photosynthesis: function (c) { _openPhotosynthesis(); },
angrybirds: function (c) { _openAngryBirds(); },
quadratic: function (c) { _openQuadratic(); },
normaldist: function (c) { _openNormalDist(); },
graphtransform: function (c) { _openGraphTransform(); },
pendulum: function (c) { _openPendulum(); },
equilibrium: function (c) { _openEquilibrium(); },
opticsbench: function (c) { _openOpticsBench(c.arg || 'lens'); },
isoprocess: function (c) { _openIsoprocess(); },
titration: function (c) { _openTitration(); },
probability: function (c) { _openProbability(); },
bohratom: function (c) { _openBohrAtom(); },
electrolysis: function (c) { _openElectrolysis(); },
race: function (c) { _openRace(); },
waves: function (c) { _openWaves(); },
hydrostatics: function (c) { _openHydro(c.arg); },
radioactive: function (c) { _openRadioactive(); },
geometry: function (c) { _openGeometry(); },
logic: function (c) { _openLogic(); },
heatengine: function (c) { _openHeatEngine(); },
stoichiometry: function (c) { _openStoich(); },
qualanalysis: function (c) { _openQualAnalysis(); },
periodic: function (c) { _openPeriodic(); },
organic: function (c) { _openOrganic(); },
solutions: function (c) { _openSolutions(); }
};
SIMS.forEach(function (s) {
if (!s.id) return; // "Скоро" — карточка без id
var open = OPEN[s.id];
if (!open) { // подстраховка: незамапленный id оставляем legacy-пути
if (window.console) console.warn('[LabRegistry] нет open() для', s.id);
return;
}
R.register({
id: s.id,
cat: s.cat,
title: s.title,
desc: s.desc,
preview: s.preview, // уже готовая SVG-строка (P_* вычислены в SIMS)
theory: T[s.id] || null,
// Фаза 3: ленивая загрузка кода. LabLoader.ensure(id) подгружает файлы
// симуляции (+ three.js при необходимости), затем выполняется raw-open.
// Если LabLoader недоступен — открываем синхронно как раньше (фолбэк).
open: (function (rawOpen, simId) {
return function (c) {
if (window.LabLoader && window.LabLoader.ensure) {
return window.LabLoader.ensure(simId).then(function () { rawOpen(c); });
}
rawOpen(c);
};
})(open, s.id)
// stop/destroy: глобальный «дробовик» _pauseAllSims()/closeSim() — паритет
});
});
// Алиасы deep-link → канонический id[:arg]. Диспетчер openSim() нормализует их
// перед обращением к реестру (карточек у алиасов нет — только прямые ссылки).
window.LAB_SIM_ALIASES = {
magnetic: 'emfield:B',
coulomb: 'emfield:E',
thinlens: 'opticsbench:lens',
mirrors: 'opticsbench:mirror',
refraction: 'opticsbench:refraction'
};
})();
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/*
* LabRegistry — единый реестр симуляций лаборатории (контент-движок).
*
* Цель: симуляции описываются декларативным манифестом и сами себя регистрируют,
* вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY).
*
* Манифест:
* {
* id: 'pendulum', // уникальный, без ':arg'
* cat: 'phys', // math | phys | chem | bio | game
* title: 'Маятник',
* desc: 'Колебания, период…',
* preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво)
* theory: { title, sections[] },// объект для панели теории (как в THEORY)
* bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2)
* mount: function(host){}, // (опц.) ленивое монтирование тела
* open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать
* stop: function(){}, // (опц.) остановить анимации (не разрушая)
* destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop
* subject, grade, topics // (опц.) курикулумные поля (Фаза 5)
* }
*
* Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал
* к моменту исполнения тел остальных модулей.
*/
(function () {
var _list = []; // манифесты в порядке регистрации
var _byId = {}; // id -> манифест
var _active = null; // текущая открытая симуляция
function _baseId(id) {
return id == null ? id : String(id).split(':')[0];
}
function register(m) {
if (!m || !m.id) return null;
if (Object.prototype.hasOwnProperty.call(_byId, m.id)) {
// перерегистрация: заменить на месте, сохранив позицию
for (var i = 0; i < _list.length; i++) {
if (_list[i].id === m.id) { _list[i] = m; break; }
}
} else {
_list.push(m);
}
_byId[m.id] = m;
return m;
}
function get(id) {
var b = _baseId(id);
return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null;
}
function has(id) { return !!get(id); }
function all() { return _list.slice(); }
function setActive(m) { _active = m || null; }
function stopActive() {
if (_active && typeof _active.stop === 'function') {
try { _active.stop(); } catch (e) { /* noop */ }
}
}
function destroyActive() {
if (_active) {
if (typeof _active.destroy === 'function') {
try { _active.destroy(); } catch (e) { /* noop */ }
} else if (typeof _active.stop === 'function') {
try { _active.stop(); } catch (e) { /* noop */ }
}
}
_active = null;
}
function active() { return _active; }
// Разрешить preview (строка или функция) в готовую разметку.
function resolvePreview(m) {
if (!m) return '';
var p = m.preview;
if (typeof p === 'function') {
try { return p() || ''; } catch (e) { return ''; }
}
return p || '';
}
window.LabRegistry = {
register: register,
get: get,
has: has,
all: all,
setActive: setActive,
stopActive: stopActive,
destroyActive: destroyActive,
active: active,
resolvePreview: resolvePreview
};
})();
+300
View File
@@ -0,0 +1,300 @@
'use strict';
/* Контент-движок, Фаза 3 — манифест зависимостей симуляций (СГЕНЕРИРОВАН).
id -> { open: имя глобальной _openX, files: [ленивые файлы], three: нужен ли three.js }.
Файлы загружаются лениво по клику (см. _loader.js). three.js — только для 3D-симуляций.
Самовосстановление в _loader: если после загрузки open-функция не определена,
грузятся ВСЕ ленивые файлы -> корректность не зависит от точности манифеста.
Регенерация: node tools/gen-sim-deps.js (см. CONTEXT). НЕ редактировать вручную. */
window.SIM_DEPS = {
"graph": {
"open": "_openGraph",
"files": [],
"three": false
},
"projectile": {
"open": "_openProjectile",
"files": [
"projectile.js"
],
"three": false
},
"collision": {
"open": "_openCollision",
"files": [
"collision.js"
],
"three": false
},
"triangle": {
"open": "_openTriangle",
"files": [
"triangle.js"
],
"three": false
},
"trigcircle": {
"open": "_openTrigCircle",
"files": [
"trigcircle.js"
],
"three": false
},
"emfield": {
"open": "_openEMField",
"files": [
"emfield.js",
"logic.js"
],
"three": false
},
"molphys": {
"open": "_openMolPhys",
"files": [
"brownian.js",
"diffusion.js",
"gas.js",
"states.js"
],
"three": false
},
"circuit": {
"open": "_openCircuit",
"files": [
"circuit.js"
],
"three": false
},
"chemistry": {
"open": "_openChemistry",
"files": [
"circuit.js",
"flask.js",
"ionexchange.js",
"reactions.js",
"redox.js"
],
"three": false
},
"dynamics": {
"open": "_openDynamics",
"files": [
"forcesandbox.js",
"newton.js"
],
"three": false
},
"crystal": {
"open": "_openCrystal",
"files": [
"crystal.js"
],
"three": true
},
"orbitals": {
"open": "_openOrbitals",
"files": [
"orbitals.js"
],
"three": true
},
"stereo": {
"open": "_openStereo",
"files": [
"stereo.js"
],
"three": true
},
"chemsandbox": {
"open": "_openChemSandbox",
"files": [
"chemsandbox.js",
"collision.js"
],
"three": false
},
"celldivision": {
"open": "_openCellDivision",
"files": [
"celldivision.js"
],
"three": false
},
"photosynthesis": {
"open": "_openPhotosynthesis",
"files": [
"photosynthesis.js"
],
"three": false
},
"angrybirds": {
"open": "_openAngryBirds",
"files": [
"angrybirds.js"
],
"three": false
},
"quadratic": {
"open": "_openQuadratic",
"files": [
"quadratic.js"
],
"three": false
},
"normaldist": {
"open": "_openNormalDist",
"files": [
"normaldist.js"
],
"three": false
},
"graphtransform": {
"open": "_openGraphTransform",
"files": [
"graphtransform.js"
],
"three": false
},
"pendulum": {
"open": "_openPendulum",
"files": [
"pendulum.js"
],
"three": false
},
"equilibrium": {
"open": "_openEquilibrium",
"files": [
"equilibrium.js"
],
"three": false
},
"opticsbench": {
"open": "_openOpticsBench",
"files": [
"opticsbench.js"
],
"three": false
},
"isoprocess": {
"open": "_openIsoprocess",
"files": [
"isoprocess.js"
],
"three": false
},
"titration": {
"open": "_openTitration",
"files": [
"titration.js"
],
"three": false
},
"probability": {
"open": "_openProbability",
"files": [
"probability.js"
],
"three": false
},
"bohratom": {
"open": "_openBohrAtom",
"files": [
"bohratom.js"
],
"three": false
},
"electrolysis": {
"open": "_openElectrolysis",
"files": [
"electrolysis.js"
],
"three": false
},
"race": {
"open": "_openRace",
"files": [
"race.js"
],
"three": false
},
"waves": {
"open": "_openWaves",
"files": [
"waves.js"
],
"three": false
},
"hydrostatics": {
"open": "_openHydro",
"files": [
"hydrostatics.js"
],
"three": false
},
"radioactive": {
"open": "_openRadioactive",
"files": [
"radioactive.js"
],
"three": false
},
"geometry": {
"open": "_openGeometry",
"files": [
"geometry.js",
"triangle.js"
],
"three": false
},
"logic": {
"open": "_openLogic",
"files": [
"logic.js"
],
"three": false
},
"heatengine": {
"open": "_openHeatEngine",
"files": [
"heatengine.js"
],
"three": false
},
"stoichiometry": {
"open": "_openStoich",
"files": [
"stoichiometry.js"
],
"three": false
},
"qualanalysis": {
"open": "_openQualAnalysis",
"files": [
"qualanalysis.js"
],
"three": false
},
"periodic": {
"open": "_openPeriodic",
"files": [
"_periodic_data.js",
"periodic.js"
],
"three": true
},
"organic": {
"open": "_openOrganic",
"files": [
"organic.js"
],
"three": false
},
"solutions": {
"open": "_openSolutions",
"files": [
"solutions.js"
],
"three": false
}
};
window.LAB_LAZY_FILES = ["angrybirds.js","bohratom.js","brownian.js","celldivision.js","chemsandbox.js","circuit.js","collision.js","crystal.js","diffusion.js","electrolysis.js","emfield.js","equilibrium.js","flask.js","forcesandbox.js","gas.js","geometry.js","graphtransform.js","heatengine.js","hydrostatics.js","ionexchange.js","isoprocess.js","logic.js","newton.js","normaldist.js","opticsbench.js","orbitals.js","organic.js","pendulum.js","periodic.js","photosynthesis.js","probability.js","projectile.js","quadratic.js","qualanalysis.js","race.js","radioactive.js","reactions.js","redox.js","solutions.js","states.js","stereo.js","stoichiometry.js","titration.js","triangle.js","trigcircle.js","waves.js","_periodic_data.js"];
+71 -3
View File
@@ -20,11 +20,25 @@
} }
function renderSims() { function renderSims() {
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter); // Контент-движок: мёрж код-реестра поверх legacy SIMS.
// Порядок берём из SIMS; для мигрированных id используем манифест реестра;
// registry-only записи добавляем в конец.
const _reg = (window.LabRegistry ? window.LabRegistry.all() : []);
const _regById = {};
_reg.forEach(m => { _regById[m.id] = m; });
const _seen = {};
const _merged = [];
SIMS.forEach(s => {
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
if (s.id) _seen[s.id] = 1;
});
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id)); const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
document.getElementById('sim-grid').innerHTML = list.map(s => ` document.getElementById('sim-grid').innerHTML = list.map(s => `
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}> <div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
${s.preview} ${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
<div class="sim-body"> <div class="sim-body">
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div> <div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
<div class="sim-title">${s.title}</div> <div class="sim-title">${s.title}</div>
@@ -935,7 +949,9 @@
} }
function loadTheory(simId) { function loadTheory(simId) {
const t = THEORY[simId]; // Контент-движок: теория мигрированных симуляций берётся из манифеста реестра.
const _rm = window.LabRegistry ? window.LabRegistry.get(simId) : null;
const t = (_rm && _rm.theory) ? _rm.theory : THEORY[simId];
const el = document.getElementById('theory-content'); const el = document.getElementById('theory-content');
if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; } if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; }
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`; let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
@@ -955,6 +971,58 @@
}); });
} }
/* ── Контент-движок, Фаза 5: чип «Связано с программой» ──────────────────
Подтягивает курикулумные связи симуляции (GET /api/lab/sims/:id/related) и
рендерит чипы-ссылки рядом с заголовком симуляции. Самодостаточно: создаёт
контейнер #sim-related динамически (без правок lab.html/CSS — меньше риск
конфликта с параллельными сессиями). Тихо прячется, если связей нет/ошибка. */
var _LAB_LINK_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
function _labRelEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function _ensureRelatedHost() {
var host = document.getElementById('sim-related');
if (host) return host;
host = document.createElement('div');
host.id = 'sim-related';
host.style.cssText = 'display:none;align-items:center;gap:6px;flex-wrap:wrap;margin-left:14px;min-width:0';
var title = document.getElementById('sim-topbar-title');
if (title && title.parentNode) title.parentNode.insertBefore(host, title.nextSibling);
return host;
}
function _loadRelated(simId) {
var host = _ensureRelatedHost();
host.style.display = 'none';
host.innerHTML = '';
if (!window.LS || !LS.api) return;
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related')
.then(function (data) {
var links = (data && data.links) || {};
var all = [].concat(links.textbook || [], links.topic || [], links.kmap || [], links.question || []);
if (!all.length) return;
var chipBase = 'display:inline-flex;align-items:center;gap:4px;font-size:.72rem;padding:3px 9px;border-radius:999px;';
var html = '<span style="font-size:.68rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em">'
+ _LAB_LINK_ICON + ' Связано с программой</span>';
all.forEach(function (l) {
var label = _labRelEsc(l.label || (l.kind + ':' + l.ref_id));
if (l.href) {
html += '<a href="' + _labRelEsc(l.href) + '" title="Открыть в учебнике" style="' + chipBase
+ 'background:rgba(155,93,229,.14);color:var(--violet);text-decoration:none;border:1px solid rgba(155,93,229,.32)">' + label + '</a>';
} else {
html += '<span style="' + chipBase
+ 'background:rgba(255,255,255,.06);color:var(--text-2);border:1px solid rgba(255,255,255,.12)">' + label + '</span>';
}
});
host.innerHTML = html;
host.style.display = 'flex';
if (window.lucide) lucide.createIcons();
})
.catch(function () { /* нет связей или ошибка — чип просто не показываем */ });
}
window._loadRelated = _loadRelated;
/* ── embed mode + auto-open from ?sim= ── */ /* ── embed mode + auto-open from ?sim= ── */
const _qp = new URLSearchParams(location.search); const _qp = new URLSearchParams(location.search);
var _embedMode = _qp.get('embed') === '1'; var _embedMode = _qp.get('embed') === '1';
+43 -52
View File
@@ -30,6 +30,19 @@
var geomSim = null; var geomSim = null;
var qualSim = null; var qualSim = null;
/* Контент-движок, Фаза 3 (ленивая загрузка): часть глобалов с экземплярами
симуляций объявляется внутри их собственных НЫНЕ ЛЕНИВЫХ файлов, поэтому до
первого открытия такой симуляции они не существуют. Legacy-«дробовик»
_pauseAllSims()/closeSim() ссылается на них по голому имени, что до загрузки
любого файла бросало ReferenceError (напр. cirSim). Предсоздаём эти имена как
свойства window (null), чтобы guard'ы безопасно давали false; при загрузке
файла симуляции его собственный var/присваивание обновит тот же глобал. */
['cirSim','reacSim','flaskSim','newtonSim','sandboxSim','crystalSim','orbitalsSim',
'stereoSim','angryBirdsSim','trigSim','pendSim','radioactiveSim','heSim',
'periodicSim','organicSim','_solutionsSim','mirrorSim'].forEach(function (_n) {
if (!(_n in window)) window[_n] = null;
});
var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield', var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield',
'sim-molphys', 'sim-molphys',
'sim-circuit','sim-chemistry','sim-dynamics', 'sim-circuit','sim-chemistry','sim-dynamics',
@@ -52,6 +65,7 @@
// Pause all animation-loop sims (non-destructive). Called when switching // Pause all animation-loop sims (non-destructive). Called when switching
// between sims so a previously opened sim doesn't keep rendering offscreen. // between sims so a previously opened sim doesn't keep rendering offscreen.
function _pauseAllSims() { function _pauseAllSims() {
if (window.LabRegistry) window.LabRegistry.stopActive();
if (pSim) pSim.pause(); if (pSim) pSim.pause();
if (cSim) cSim.pause(); if (cSim) cSim.pause();
if (gasSim) gasSim.stop(); if (gasSim) gasSim.stop();
@@ -105,58 +119,34 @@
// load theory for this sim // load theory for this sim
loadTheory(id.includes(':') ? id.split(':')[0] : id); loadTheory(id.includes(':') ? id.split(':')[0] : id);
if (id === 'graph') _openGraph(); // Фаза 5: чип «Связано с программой» (курикулумные связи симуляции).
if (id === 'projectile') _openProjectile(); if (typeof _loadRelated === 'function') _loadRelated(id.includes(':') ? id.split(':')[0] : id);
if (id === 'collision') _openCollision();
if (id === 'triangle') _openTriangle(); // ── Контент-движок (Фаза 1): диспетчеризация через реестр ──
if (id === 'trigcircle') _openTrigCircle(); // Все каталожные симуляции зарегистрированы в _register-all.js.
if (id === 'magnetic') _openEMField('B'); // backward compat: #magnetic → emfield B-mode // Алиасы deep-link (magnetic/coulomb/thinlens/mirrors/refraction) нормализуем
if (id === 'coulomb') _openEMField('E'); // backward compat: #coulomb → emfield E-mode // в канонический id[:arg] перед обращением к реестру.
if (id === 'emfield') _openEMField('E'); var _aliases = window.LAB_SIM_ALIASES || {};
if (id.startsWith('emfield:')) { _openEMField(id.split(':')[1]); } var _cid = _aliases[id.split(':')[0]] || id;
if (id === 'molphys') _openMolPhys(); if (window.LabRegistry && window.LabRegistry.has(_cid)) {
if (id.startsWith('molphys:')) { _openMolPhys(id.split(':')[1]); } const _m = window.LabRegistry.get(_cid);
if (id === 'circuit') _openCircuit(); const _arg = _cid.includes(':') ? _cid.split(':')[1] : undefined;
if (id === 'chemistry') _openChemistry(); window.LabRegistry.setActive(_m);
if (id.startsWith('chemistry:')) { _openChemistry(id.split(':')[1]); } // Фаза 3: open() может вернуть Promise (ленивая загрузка кода). Иконки
if (id === 'dynamics') _openDynamics(); // перерисовываем после фактической инициализации тела симуляции; ошибку
if (id.startsWith('dynamics:')) { _openDynamics(id.split(':')[1]); } // асинхронной загрузки ловим через .catch (sync try/catch её не поймает).
if (id === 'crystal') _openCrystal(); try {
if (id === 'orbitals') _openOrbitals(); const _r = _m.open({ id: _cid, arg: _arg });
if (id === 'stereo') _openStereo(); if (_r && typeof _r.then === 'function') {
if (id.startsWith('stereo:')) { _openStereo(id.split(':')[1]); } _r.then(function () { if (window.lucide) lucide.createIcons(); })
if (id === 'chemsandbox') _openChemSandbox(); .catch(function (e) { console.error('[LabRegistry] open failed:', _cid, e); });
if (id === 'celldivision') _openCellDivision(); } else if (window.lucide) {
if (id === 'photosynthesis') _openPhotosynthesis(); lucide.createIcons();
if (id === 'angrybirds') _openAngryBirds(); }
if (id === 'quadratic') _openQuadratic(); } catch (e) { console.error('[LabRegistry] open failed:', _cid, e); }
if (id === 'normaldist') _openNormalDist(); return;
if (id === 'graphtransform') _openGraphTransform(); }
if (id === 'pendulum') _openPendulum(); if (window.console) console.warn('[LabRegistry] неизвестная симуляция:', id);
if (id === 'equilibrium') _openEquilibrium();
if (id === 'opticsbench') _openOpticsBench('lens');
if (id.startsWith('opticsbench:')) _openOpticsBench(id.split(':')[1]);
if (id === 'thinlens') _openOpticsBench('lens'); // backward compat
if (id === 'mirrors') _openOpticsBench('mirror'); // backward compat
if (id === 'refraction') _openOpticsBench('refraction'); // backward compat
if (id === 'isoprocess') _openIsoprocess();
if (id === 'titration') _openTitration();
if (id === 'probability') _openProbability();
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'race') _openRace();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
if (id === 'radioactive') _openRadioactive();
if (id === 'geometry') _openGeometry();
if (id === 'logic') _openLogic();
if (id === 'heatengine') _openHeatEngine();
if (id === 'stoichiometry') _openStoich();
if (id === 'qualanalysis') _openQualAnalysis();
if (id === 'periodic') _openPeriodic();
if (id === 'organic') _openOrganic();
if (id === 'solutions') _openSolutions();
} }
function _simShow(elId) { function _simShow(elId) {
@@ -210,6 +200,7 @@
} }
function closeSim() { function closeSim() {
if (window.LabRegistry) window.LabRegistry.destroyActive();
if (pSim) pSim.pause(); if (pSim) pSim.pause();
if (cSim) cSim.pause(); if (cSim) cSim.pause();
if (mSim && mSim.particleOn) mSim.toggleParticle(); if (mSim && mSim.particleOn) mSim.toggleParticle();
+39 -4475
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -454,16 +454,28 @@
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
} }
let labLinks = {}; // { textbook_slug: [{id,title,cat}] } — связанные симуляции (Фаза 5)
async function loadTextbooks() { async function loadTextbooks() {
try { try {
const r = await LS.api('/api/textbooks'); const [r, labRes] = await Promise.all([
LS.api('/api/textbooks'),
LS.api('/api/lab/links/all?kind=textbook').catch(() => ({ byRef: {} })),
]);
textbooks = r.textbooks || []; textbooks = r.textbooks || [];
labLinks = (labRes && labRes.byRef) || {};
render(); render();
} catch (e) { } catch (e) {
document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`; document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`;
} }
} }
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
function openLabSim(simId, ev) {
if (ev) ev.stopPropagation();
location.href = '/lab?sim=' + encodeURIComponent(simId);
}
window.openLabSim = openLabSim;
function render() { function render() {
const grid = document.getElementById('tb-grid'); const grid = document.getElementById('tb-grid');
if (!textbooks.length) { if (!textbooks.length) {
@@ -509,6 +521,10 @@
${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ"> ${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
</button>` : ''} </button>` : ''}
${(labLinks[t.slug] && labLinks[t.slug].length) ? `<button class="tb-btn tb-lab-btn" onclick="openLabSim('${esc(labLinks[t.slug][0].id)}', event)" title="Открыть связанную симуляцию в лаборатории">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>
В лабораторию${labLinks[t.slug].length > 1 ? ' (' + labLinks[t.slug].length + ')' : ''}
</button>` : ''}
</div> </div>
</div> </div>
</article>`; </article>`;
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 1 · «Важнейшие классы неорганических соединений»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 1 — teal-палитра */
:root{ --pri:#0d9488; --pri-d:#0f766e; --pri-l:#2dd4bf; --pri-soft:#ccfbf1; --sec-acc:#0d9488; --sec-acc-d:#0f766e; --sec-acc-soft:#ccfbf1; }
html.dark{ --bg:#0c1a18; --card:#102825; --card-soft:#13302c; --text:#ccfbf1; --muted:#7fd8c8; --border:#1d463f; --pri-soft:rgba(13,148,136,.2); --sec-acc-soft:rgba(13,148,136,.2); }
.hdr{background:linear-gradient(110deg,#134e4a 0%,#0d9488 55%,#2dd4bf 100%)}
.hdr::before{content:'ГЛАВА 1'}
</style>
<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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch1_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 1</h1>
<div class="hdr-sub">Оксиды, кислоты, основания и соли: состав, классификация, свойства, получение и генетическая связь</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Четыре класса, из которых построена неорганическая химия</h2>
<p>Оксиды, кислоты, основания и соли связаны между собой превращениями. Научившись узнавать класс вещества по формуле и предсказывать его реакции, ты сможешь «читать» химию как язык.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p10')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 10</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p10" class="sec"><div class="sec-header"><span class="sec-num">§ 10</span><h2 class="sec-h">Оксиды. Состав и классификация</h2></div><div id="p10-body"></div></section>
<section id="sec-p11" class="sec"><div class="sec-header"><span class="sec-num">§ 11</span><h2 class="sec-h">Химические свойства оксидов</h2></div><div id="p11-body"></div></section>
<section id="sec-p12" class="sec"><div class="sec-header"><span class="sec-num">§ 12</span><h2 class="sec-h">Получение и применение оксидов</h2></div><div id="p12-body"></div></section>
<section id="sec-p13" class="sec"><div class="sec-header"><span class="sec-num">§ 13</span><h2 class="sec-h">Кислоты. Состав и классификация</h2></div><div id="p13-body"></div></section>
<section id="sec-p14" class="sec"><div class="sec-header"><span class="sec-num">§ 14</span><h2 class="sec-h">Химические свойства кислот</h2></div><div id="p14-body"></div></section>
<section id="sec-p15" class="sec"><div class="sec-header"><span class="sec-num">§ 15</span><h2 class="sec-h">Получение и применение кислот</h2></div><div id="p15-body"></div></section>
<section id="sec-p16" class="sec"><div class="sec-header"><span class="sec-num">§ 16</span><h2 class="sec-h">Основания</h2></div><div id="p16-body"></div></section>
<section id="sec-p17" class="sec"><div class="sec-header"><span class="sec-num">§ 17</span><h2 class="sec-h">Химические свойства оснований</h2></div><div id="p17-body"></div></section>
<section id="sec-p18" class="sec"><div class="sec-header"><span class="sec-num">§ 18</span><h2 class="sec-h">Получение оснований · Лаб. 1 · ПР 2</h2></div><div id="p18-body"></div></section>
<section id="sec-p19" class="sec"><div class="sec-header"><span class="sec-num">§ 19</span><h2 class="sec-h">Соли. Состав и классификация</h2></div><div id="p19-body"></div></section>
<section id="sec-p20" class="sec"><div class="sec-header"><span class="sec-num">§ 20</span><h2 class="sec-h">Химические свойства солей · Лаб. 2</h2></div><div id="p20-body"></div></section>
<section id="sec-p21" class="sec"><div class="sec-header"><span class="sec-num">§ 21</span><h2 class="sec-h">Получение и применение солей</h2></div><div id="p21-body"></div></section>
<section id="sec-p22" class="sec"><div class="sec-header"><span class="sec-num">§ 22</span><h2 class="sec-h">Взаимосвязь классов · ПР 3</h2></div><div id="p22-body"></div></section>
<section id="sec-p23" class="sec"><div class="sec-header"><span class="sec-num">§ 23</span><h2 class="sec-h">Решение расчётных задач</h2></div><div id="p23-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 1 · «Важнейшие классы неорганических соединений» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch1', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch1_progress', achKey:'chemistry8_ch1_ach' };
window.PARAS = [
{id:'p10',num:'§ 10',name:'Оксиды. Классификация',sub:'осн/кисл/амф'},
{id:'p11',num:'§ 11',name:'Свойства оксидов',sub:'+ вода/кислота/щёлочь'},
{id:'p12',num:'§ 12',name:'Получение оксидов',sub:'горение · разложение'},
{id:'p13',num:'§ 13',name:'Кислоты. Классификация',sub:'основность'},
{id:'p14',num:'§ 14',name:'Свойства кислот',sub:'+ Me · ряд активности'},
{id:'p15',num:'§ 15',name:'Получение кислот',sub:'оксид + вода'},
{id:'p16',num:'§ 16',name:'Основания',sub:'щёлочи/нераств.'},
{id:'p17',num:'§ 17',name:'Свойства оснований',sub:'нейтрализация'},
{id:'p18',num:'§ 18',name:'Получение · Лаб.1 · ПР2',sub:'Cu(OH)₂↓'},
{id:'p19',num:'§ 19',name:'Соли. Классификация',sub:'растворимость'},
{id:'p20',num:'§ 20',name:'Свойства солей · Лаб.2',sub:'РИО · соль+Me'},
{id:'p21',num:'§ 21',name:'Получение солей',sub:'способы'},
{id:'p22',num:'§ 22',name:'Связь классов · ПР3',sub:'генетика'},
{id:'p23',num:'§ 23',name:'Расчётные задачи',sub:'по уравнениям'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 1!', final1_tasks:'Классы веществ покорены!' };
['p10','p11','p12','p13','p14','p15','p16','p17','p18','p19','p20','p21','p22','p23'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p10:{title:'§10 Оксиды',rows:[['Оксид','ЭₓOᵧ, кислород 2'],['Основные','оксиды металлов'],['Кислотные','оксиды неметаллов'],['Амфотерные','ZnO, Al₂O₃']]},
p11:{title:'§11 Свойства',rows:[['Осн. оксид','+ кислота, + вода'],['Кисл. оксид','+ щёлочь, + вода'],['Соль','продукт + H₂O']]},
p12:{title:'§12 Получение',rows:[['Горение','S + O₂ → SO₂'],['Разложение','CaCO₃ → CaO + CO₂']]},
p13:{title:'§13 Кислоты',rows:[['Кислота','HₓAc'],['Бескислородные','HCl, H₂S'],['Кислородсод.','H₂SO₄, HNO₃'],['Основность','число H']]},
p14:{title:'§14 Свойства кислот',rows:[['+ Me','до H в ряду → H₂↑'],['+ осн. оксид','соль + вода'],['+ основание','нейтрализация'],['Индикатор','лакмус → красный']]},
p15:{title:'§15 Получение',rows:[['Кисл. оксид + вода','SO₃+H₂O→H₂SO₄'],['Соль + кислота','']]},
p16:{title:'§16 Основания',rows:[['Основание','Me(OH)ₙ'],['Щёлочи','NaOH, KOH, Ba(OH)₂'],['Нерастворимые','Cu(OH)₂, Fe(OH)₃'],['Фенолфталеин','малиновый в щёлочи']]},
p17:{title:'§17 Свойства',rows:[['Нейтрализация','+ кислота → соль+H₂O'],['+ кисл. оксид','соль + вода'],['Разложение','Cu(OH)₂ → CuO+H₂O']]},
p18:{title:'§18 Получение',rows:[['Щёлочь','Me + вода'],['Нераств.','щёлочь + соль'],['Лаб.1','Cu(OH)₂↓ голубой']]},
p19:{title:'§19 Соли',rows:[['Соль','катион металла + анион'],['Средние','NaCl, CaCO₃'],['Растворимость','таблица']]},
p20:{title:'§20 Свойства',rows:[['РИО','↓ ↑ H₂O'],['Соль + Me','активнее вытесняет'],['Лаб.2','соль + металл']]},
p21:{title:'§21 Получение',rows:[['Кислота+основание',''],['Металл+кислота',''],['Оксид+кислота','']]},
p22:{title:'§22 Связь',rows:[['Металл','→ оксид → основание → соль'],['Неметалл','→ оксид → кислота → соль']]},
p23:{title:'§23 Расчёты',rows:[['Алгоритм','m→n→n→m'],['По уравнению','мольное отношение']]},
final1:{title:'Финал главы 1',rows:[['§§1023','все классы'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p10',html:'Оксид — соединение элемента с кислородом (степень окисления −2). Оксиды металлов чаще основные, неметаллов — кислотные.'},
{sec:'p11',html:'Основный оксид реагирует с кислотой, кислотный — со щёлочью. Оба могут давать соль.'},
{sec:'p12',html:'Оксиды получают горением простых веществ и разложением солей/оснований.'},
{sec:'p13',html:'Основность кислоты = число атомов H, способных замещаться металлом (HCl — одноосновная, H₂SO₄ — двухосновная).'},
{sec:'p14',html:'Металлы ДО водорода в ряду активности вытесняют H₂ из растворов кислот; после — нет.'},
{sec:'p15',html:'Кислородсодержащие кислоты получают: кислотный оксид + вода. Например, SO₃ + H₂O → H₂SO₄.'},
{sec:'p16',html:'Растворимые основания — щёлочи (NaOH, KOH, Ba(OH)₂). Фенолфталеин в щёлочи становится малиновым.'},
{sec:'p17',html:'Реакция нейтрализации: основание + кислота → соль + вода.'},
{sec:'p18',html:'Нерастворимое основание получают: щёлочь + соль. Cu(OH)₂ — голубой осадок.'},
{sec:'p19',html:'Растворимость соли определяют по таблице. Нерастворимая соль выпадает в осадок.'},
{sec:'p20',html:'Реакции ионного обмена идут, если образуется осадок ↓, газ ↑ или вода. Металл вытесняет из соли менее активный металл.'},
{sec:'p21',html:'Соли получают многими способами: кислота+основание, металл+кислота, оксид+кислота, соль+соль и др.'},
{sec:'p22',html:'Генетическая связь: металл → основный оксид → основание → соль; неметалл → кислотный оксид → кислота → соль.'},
{sec:'p23',html:'Расчёт по уравнению: найди n известного, по коэффициентам — n искомого, переведи в массу/объём.'},
{sec:'final1',html:'Собери всё: классификация, свойства, растворимость, ряд активности, расчёты по уравнениям.'}
];
window.POOLS = {
p10:[
{q:'Оксид — это…',opts:['Соединение двух неметаллов','Сложное вещество из двух элементов, один из которых кислород (с.о. −2)','Любое вещество с кислородом','Соль кислородной кислоты'],a:1,ex:'Оксид — бинарное соединение элемента с кислородом.'},
{q:'Какой из оксидов основный?',opts:['CO₂','SO₃','Na₂O','P₂O₅'],a:2,ex:'Na₂O — оксид активного металла, основный.'},
{q:'Амфотерный оксид — это…',opts:['Na₂O','CaO','ZnO','CO'],a:2,ex:'ZnO проявляет и основные, и кислотные свойства.'},
{q:'Несолеобразующий оксид:',opts:['CO','SO₂','CaO','Al₂O₃'],a:0,ex:'CO не образует солей — несолеобразующий.'}
],
p11:[
{q:'Основный оксид реагирует с…',opts:['Основанием','Кислотой','Другим основным оксидом','Инертным газом'],a:1,ex:'Основный оксид + кислота → соль + вода.'},
{q:'Кислотный оксид реагирует с…',opts:['Кислотой','Щёлочью','Кислотным оксидом','Металлом'],a:1,ex:'Кислотный оксид + щёлочь → соль + вода.'},
{q:'Продукт реакции CaO + H₂O:',opts:['CaCO₃','Ca(OH)₂','CaCl₂','CaO₂'],a:1,ex:'Основный оксид активного металла + вода → щёлочь.'}
],
p12:[
{q:'Каким способом получают оксиды?',opts:['Только электролизом','Горением простых веществ и разложением солей','Только из кислот','Только из щелочей'],a:1,ex:'Горение (S+O₂→SO₂) и разложение (CaCO₃→CaO+CO₂).'},
{q:'При разложении 1 моль CaCO₃ образуется … моль CO₂',hint:'1:1',unit:'моль',a:1,ex:'CaCO₃ → CaO + CO₂, отношение 1:1.'}
],
p13:[
{q:'Бескислородная кислота — это…',opts:['H₂SO₄','HNO₃','HCl','H₃PO₄'],a:2,ex:'HCl не содержит кислорода.'},
{q:'Основность серной кислоты H₂SO₄ равна…',hint:'число атомов H',unit:'',a:2,ex:'Два атома водорода → двухосновная.'},
{q:'Какого цвета лакмус в растворе кислоты?',opts:['Синий','Красный','Жёлтый','Бесцветный'],a:1,ex:'Лакмус в кислоте — красный.'}
],
p14:[
{q:'Какой металл вытесняет водород из соляной кислоты?',opts:['Cu','Ag','Zn','Au'],a:2,ex:'Zn стоит до H в ряду активности.'},
{q:'Реакция кислоты с основанием называется…',opts:['Разложения','Нейтрализации','Замещения','Горения'],a:1,ex:'Кислота + основание → соль + вода — нейтрализация.'},
{q:'Медь (Cu) с разбавленной HCl…',opts:['Бурно реагирует','Не реагирует (стоит после H)','Образует H₂','Растворяется с осадком'],a:1,ex:'Cu после водорода — H₂ не вытесняет.'}
],
p15:[
{q:'H₂SO₄ можно получить реакцией…',opts:['SO₃ + H₂O','SO₂ + H₂','S + H₂O','Na₂SO₄ + H₂O'],a:0,ex:'Кислотный оксид SO₃ + вода → серная кислота.'},
{q:'Какой оксид при растворении в воде даёт кислоту?',opts:['CaO','Na₂O','CO₂','MgO'],a:2,ex:'CO₂ + H₂O → H₂CO₃ (кислотный оксид).'}
],
p16:[
{q:'Щёлочь — это…',opts:['Любое основание','Растворимое в воде основание','Нерастворимое основание','Кислота'],a:1,ex:'Щёлочи — растворимые основания (NaOH, KOH...).'},
{q:'Нерастворимое основание:',opts:['NaOH','KOH','Cu(OH)₂','Ba(OH)₂'],a:2,ex:'Cu(OH)₂ нерастворим.'},
{q:'Фенолфталеин в растворе щёлочи становится…',opts:['Красным','Малиновым','Жёлтым','Синим'],a:1,ex:'В щёлочи фенолфталеин малиновый.'}
],
p17:[
{q:'Продукты реакции NaOH + HCl:',opts:['NaCl + H₂','NaCl + H₂O','Na₂O + HCl','NaH + ClOH'],a:1,ex:'Нейтрализация: NaCl + H₂O.'},
{q:'При нагревании Cu(OH)₂ разлагается на…',opts:['Cu + H₂O','CuO + H₂O','CuO + H₂','Cu + O₂'],a:1,ex:'Cu(OH)₂ → CuO + H₂O.'}
],
p18:[
{q:'Cu(OH)₂ в лаборатории получают реакцией…',opts:['Cu + H₂O','CuSO₄ + NaOH','CuO + H₂O','Cu + NaOH'],a:1,ex:'Соль + щёлочь → нерастворимое основание Cu(OH)₂↓.'},
{q:'Какого цвета осадок Cu(OH)₂?',opts:['Белый','Голубой','Бурый','Чёрный'],a:1,ex:'Гидроксид меди(II) — голубой осадок.'}
],
p19:[
{q:'Соль — это…',opts:['Оксид металла','Продукт замещения H кислоты на металл','Гидроксид','Простое вещество'],a:1,ex:'В соли водород кислоты замещён металлом.'},
{q:'Какая соль нерастворима в воде?',opts:['NaCl','KNO₃','BaSO₄','CaCl₂'],a:2,ex:'BaSO₄ — нерастворим (см. таблицу).'},
{q:'Все нитраты (NO₃⁻)…',opts:['Нерастворимы','Растворимы','Малорастворимы','Разлагаются водой'],a:1,ex:'Нитраты растворимы все.'}
],
p20:[
{q:'Реакция ионного обмена идёт, если образуется…',opts:['Только газ','Осадок ↓, газ ↑ или вода','Только осадок','Новая кислота'],a:1,ex:'Условие необратимости РИО.'},
{q:'Какой металл вытеснит медь из раствора CuSO₄?',opts:['Ag','Au','Fe','Hg'],a:2,ex:'Fe активнее Cu → вытесняет медь.'},
{q:'Реакция AgNO₃ + NaCl даёт осадок…',opts:['AgNO₃','NaCl','AgCl↓','NaNO₃'],a:2,ex:'AgCl — белый осадок ↓.'}
],
p21:[
{q:'Какой способ НЕ даёт соль?',opts:['Кислота + основание','Металл + кислота','Оксид + кислота','Два инертных газа'],a:3,ex:'Инертные газы не реагируют.'},
{q:'Zn + 2HCl даёт соль…',opts:['ZnO','ZnCl₂','Zn(OH)₂','ZnSO₄'],a:1,ex:'ZnCl₂ + H₂↑.'}
],
p22:[
{q:'Генетический ряд металла:',opts:['Me → кислота → соль','Me → основный оксид → основание → соль','Me → кислотный оксид → кислота','Me → соль → оксид'],a:1,ex:'Металл → осн. оксид → основание → соль.'},
{q:'Из чего можно получить кислоту в ряду неметалла?',opts:['Из основного оксида','Из кислотного оксида + вода','Из металла','Из щёлочи'],a:1,ex:'Кислотный оксид + вода → кислота.'}
],
p23:[
{q:'CaO + 2HCl → CaCl₂ + H₂O. Дано n(CaO)=0,5 моль. Найди n(CaCl₂).',hint:'1:1',unit:'моль',a:0.5,ex:'Отношение 1:1 → 0,5 моль.'},
{q:'Zn + H₂SO₄ → ZnSO₄ + H₂↑. Дано n(Zn)=2 моль. Найди V(H₂) при н.у.',hint:'n(H₂)=2; V=n·22,4',unit:'л',a:44.8,ex:'2·22,4=44,8 л.'},
{q:'2NaOH + H₂SO₄ → Na₂SO₄ + 2H₂O. Дано n(H₂SO₄)=0,5 моль. Найди n(NaOH).',hint:'2:1',unit:'моль',a:1,ex:'2·0,5=1 моль.'}
],
final1:[
{q:'Какой оксид кислотный?',opts:['CaO','Na₂O','SO₃','MgO'],a:2,ex:'SO₃ — оксид неметалла, кислотный.'},
{q:'Основность H₃PO₄ равна…',hint:'число H',unit:'',a:3,ex:'Три атома H — трёхосновная.'},
{q:'Какой металл НЕ вытесняет H₂ из кислоты?',opts:['Mg','Zn','Fe','Cu'],a:3,ex:'Cu стоит после H.'},
{q:'Реакция CuSO₄ + 2NaOH даёт осадок…',opts:['CuO','Cu(OH)₂↓','Na₂SO₄','Cu'],a:1,ex:'Cu(OH)₂ — голубой осадок ↓.'},
{q:'Дано: m(CaCO₃)=50 г, M=100. Найди n.',hint:'n=m/M',unit:'моль',a:0.5,ex:'50/100=0,5 моль.'},
{q:'Zn + 2HCl → ZnCl₂ + H₂↑. n(Zn)=1 моль. Найди V(H₂) н.у.',hint:'V=n·22,4',unit:'л',a:22.4,ex:'1·22,4=22,4 л.'},
{q:'Mr(Cu(OH)₂)?',hint:'64+2·(16+1)',unit:'',a:98,ex:'64+34=98.'},
{q:'Какого цвета осадок Fe(OH)₃?',opts:['Голубой','Бурый','Белый','Чёрный'],a:1,ex:'Fe(OH)₃ — бурый.'} /*U5-extra*/
]
};
/* конструкторы */
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function wgt(title, inner){ return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
function tube(o){ return (window.Chem8&&window.Chem8.testTube)?window.Chem8.testTube(o):''; }
window.BUILDERS = {
p10:bp10,p11:bp11,p12:bp12,p13:bp13,p14:bp14,p15:bp15,p16:bp16,p17:bp17,p18:bp18,p19:bp19,p20:bp20,p21:bp21,p22:bp22,p23:bp23,final1:bfinal
};
function bp10(){ document.getElementById('p10-body').innerHTML =
hero(1,'§ 10 · Глава 1','Оксиды. Состав и классификация','ЭₓOᵧ','Самый распространённый класс: соединения элементов с кислородом — от песка и воды до углекислого газа.',['оксид','классификация','амфотерность'])
+makeCard('theory','Что такое оксид','§10','<p><b>Оксид</b> — сложное вещество из двух элементов, один из которых — кислород в степени окисления −2 (Na₂O, CaO, CO₂, SO₃, H₂O).</p><p>Оксиды делят на <b>солеобразующие</b> (дают соли) и <b>несолеобразующие</b> (CO, N₂O, NO). Солеобразующие — на <b>основные</b> (оксиды металлов: Na₂O, CaO), <b>кислотные</b> (оксиды неметаллов: CO₂, SO₃, P₂O₅) и <b>амфотерные</b> (ZnO, Al₂O₃).</p>')
+flag('Классификатор оксидов','Перетащи (кликни чип, затем корзину) каждый оксид в свой класс и нажми «Проверить».','<div id="c-ox-cls"></div>')
+rememberBox(['Оксиды металлов чаще основные, неметаллов — кислотные.','Амфотерные: ZnO, Al₂O₃, Cr₂O₃ — ведут себя двойственно.','CO, NO, N₂O — несолеобразующие.'])
+qList(['Чем основный оксид отличается от кислотного?','Приведи по два примера оксидов каждого класса.'])
+secNav(null,'p11')+readButton('p10'); wireReadBtn('p10'); }
function bp11(){ document.getElementById('p11-body').innerHTML =
hero(2,'§ 11 · Глава 1','Химические свойства оксидов','оксид + … → соль','Как основные и кислотные оксиды реагируют с водой, кислотами и щелочами, образуя соли.',['+ вода','+ кислота','+ щёлочь'])
+makeCard('theory','Свойства оксидов','§11','<p><b>Основные оксиды:</b><br>• + вода (только активные металлы) → щёлочь: CaO + H₂O → Ca(OH)₂<br>• + кислота → соль + вода: CuO + 2HCl → CuCl₂ + H₂O</p><p><b>Кислотные оксиды:</b><br>• + вода → кислота: SO₃ + H₂O → H₂SO₄<br>• + щёлочь → соль + вода: CO₂ + 2NaOH → Na₂CO₃ + H₂O</p>')
+makeCard('example','Признак реакции',null,'<div class="tt-row"><div><div style="text-align:center">'+tube({color:'#dbeafe'})+'</div><div class="tt-cap">CuO + кислота → голубой раствор соли</div></div></div>')
+rememberBox(['Основный + кислотный оксид → соль (CaO + CO₂ → CaCO₃).','Не все оксиды реагируют с водой (CuO, Fe₂O₃ — нет).'])
+qList(['Допиши: SO₂ + NaOH → ?','С чем реагирует основный оксид CaO?'])
+secNav('p10','p12')+readButton('p11'); wireReadBtn('p11'); }
function bp12(){ document.getElementById('p12-body').innerHTML =
hero(3,'§ 12 · Глава 1','Получение и применение оксидов','S + O₂ → SO₂','Откуда берут оксиды и где их применяют — от извести до кремнезёма.',['горение','разложение','применение'])
+makeCard('theory','Способы получения','§12','<p>• <b>Горение</b> простых веществ: S + O₂ → SO₂; 2Cu + O₂ → 2CuO.<br>• <b>Разложение</b> солей и оснований: CaCO₃ →(t) CaO + CO₂↑; Cu(OH)₂ →(t) CuO + H₂O.</p><p><b>Применение:</b> CaO (негашёная известь) — в строительстве, SiO₂ — стекло, CO₂ — газировка и огнетушители.</p>')
+rememberBox(['Горение неметалла даёт кислотный оксид, металла — основный.','Карбонаты при нагревании разлагаются на оксид и CO₂.'])
+qList(['Напиши уравнение горения углерода.','Где применяют негашёную известь CaO?'])
+secNav('p11','p13')+readButton('p12'); wireReadBtn('p12'); }
function bp13(){ document.getElementById('p13-body').innerHTML =
hero(4,'§ 13 · Глава 1','Кислоты. Состав и классификация','HₓAc','Вещества с кислым вкусом и подвижными атомами водорода: как их различают и классифицируют.',['основность','индикатор'])
+makeCard('theory','Кислоты и их классификация','§13','<p><b>Кислота</b> — сложное вещество, в составе которого есть атомы водорода, способные замещаться металлом, и кислотный остаток.</p><p>По наличию кислорода: <b>бескислородные</b> (HCl, H₂S, HBr) и <b>кислородсодержащие</b> (H₂SO₄, HNO₃, H₃PO₄). По числу атомов H — <b>основность</b>: одноосновные (HCl), двухосновные (H₂SO₄), трёхосновные (H₃PO₄).</p>')
+flag('Классификатор кислот','Распредели кислоты по наличию кислорода.','<div id="c-acid-cls"></div>')
+wgt('Индикатор: кислая среда','<div id="c-acid-ind"></div>')
+rememberBox(['Основность = число замещаемых атомов H.','Лакмус в кислоте красный, метилоранж — розово-красный.'])
+qList(['Определи основность H₃PO₄.','Какие кислоты бескислородные?'])
+secNav('p12','p14')+readButton('p13'); wireReadBtn('p13'); }
function bp14(){ document.getElementById('p14-body').innerHTML =
hero(5,'§ 14 · Глава 1','Химические свойства кислот','+ Me · + оксид · + основание','Четыре главные реакции кислот и роль ряда активности металлов.',['ряд активности','нейтрализация'])
+makeCard('theory','Свойства кислот','§14','<p>Кислоты реагируют:<br>• с <b>металлами</b> (до H в ряду активности) → соль + H₂↑: Zn + 2HCl → ZnCl₂ + H₂↑<br>• с <b>основными оксидами</b> → соль + вода: CuO + H₂SO₄ → CuSO₄ + H₂O<br>• с <b>основаниями</b> (нейтрализация) → соль + вода<br>• с <b>солями</b> → новая соль + новая кислота (если ↓↑).</p>')
+flag('Ряд активности металлов','Кликни металл — узнаешь, вытесняет ли он водород из кислот и какие металлы вытесняет из солей.','<div id="c-acid-act"></div>')
+wgt('Индикатор: метилоранж','<div id="c-acid-ind2"></div>')
+rememberBox(['Металлы после H (Cu, Ag, Au) водород из кислот НЕ вытесняют.','Признак реакции с металлом — выделение пузырьков H₂↑.'])
+qList(['Будет ли реагировать Ag с HCl? Почему?','Допиши: Fe + HCl → ?'])
+secNav('p13','p15')+readButton('p14'); wireReadBtn('p14'); }
function bp15(){ document.getElementById('p15-body').innerHTML =
hero(6,'§ 15 · Глава 1','Получение и применение кислот','SO₃ + H₂O → H₂SO₄','Как получают кислоты и где их используют — от аккумуляторов до пищевой промышленности.',['получение','применение'])
+makeCard('theory','Получение кислот','§15','<p>• <b>Кислотный оксид + вода</b> → кислота: SO₃ + H₂O → H₂SO₄; CO₂ + H₂O → H₂CO₃.<br>• <b>Соль + сильная кислота</b> → новая кислота: BaCl₂ + H₂SO₄ → BaSO₄↓ + 2HCl.</p><p><b>Применение:</b> H₂SO₄ — «хлеб химии» (аккумуляторы, удобрения), HCl — в металлургии, H₃PO₄ — пищевая.</p>')
+rememberBox(['Бескислородные кислоты не получить «оксид + вода» — у них нет кислорода.','H₂SO₄ — одно из важнейших веществ промышленности.'])
+qList(['Из какого оксида получают угольную кислоту?','Где применяют серную кислоту?'])
+secNav('p14','p16')+readButton('p15'); wireReadBtn('p15'); }
function bp16(){ document.getElementById('p16-body').innerHTML =
hero(7,'§ 16 · Глава 1','Основания','Me(OH)ₙ','Гидроксиды металлов: растворимые щёлочи и нерастворимые основания.',['щёлочи','индикатор'])
+makeCard('theory','Основания','§16','<p><b>Основание</b> — сложное вещество из атома металла и гидроксогрупп OH. По растворимости: <b>щёлочи</b> (растворимые: NaOH, KOH, Ca(OH)₂, Ba(OH)₂) и <b>нерастворимые</b> (Cu(OH)₂, Fe(OH)₃, Mg(OH)₂).</p><p>Щёлочи изменяют окраску индикаторов: лакмус → синий, фенолфталеин → малиновый, метилоранж → жёлтый.</p>')
+flag('Классификатор оснований','Раздели основания на щёлочи и нерастворимые.','<div id="c-base-cls"></div>')
+wgt('Индикатор: фенолфталеин в щёлочи','<div id="c-base-ind"></div>')
+rememberBox(['Щёлочи — растворимые основания активных металлов (I–II групп).','Фенолфталеин — главный индикатор на щёлочь (малиновый).'])
+qList(['Назови три щёлочи.','Почему Cu(OH)₂ не относят к щелочам?'])
+secNav('p15','p17')+readButton('p16'); wireReadBtn('p16'); }
function bp17(){ document.getElementById('p17-body').innerHTML =
hero(8,'§ 17 · Глава 1','Химические свойства оснований','нейтрализация','Реакции щелочей и нерастворимых оснований: соль, вода и разложение при нагревании.',['+ кислота','+ оксид','разложение'])
+makeCard('theory','Свойства оснований','§17','<p>• <b>Нейтрализация:</b> основание + кислота → соль + вода: NaOH + HCl → NaCl + H₂O.<br>• Щёлочь + <b>кислотный оксид</b> → соль + вода: 2NaOH + CO₂ → Na₂CO₃ + H₂O.<br>• <b>Разложение</b> нерастворимых оснований при нагревании: Cu(OH)₂ →(t) CuO + H₂O.</p>')
+wgt('Индикатор: нейтрализация (двигай pH)','<div id="c-neutral-ind"></div>')
+rememberBox(['Реакция нейтрализации идёт всегда (образуется вода).','Щёлочи термически устойчивы, нерастворимые основания — разлагаются.'])
+qList(['Допиши: KOH + H₂SO₄ → ?','Что образуется при нагревании Fe(OH)₃?'])
+secNav('p16','p18')+readButton('p17'); wireReadBtn('p17'); }
function bp18(){ document.getElementById('p18-body').innerHTML =
hero(9,'§ 18 · Глава 1','Получение оснований · Лаб. 1 · ПР 2','CuSO₄ + 2NaOH → Cu(OH)₂↓','Как получают щёлочи и нерастворимые основания; лабораторный опыт и реакция нейтрализации.',['Лаб.1','ПР.2','осадок'])
+makeCard('theory','Получение оснований','§18','<p>• <b>Щёлочи:</b> активный металл + вода (2Na + 2H₂O → 2NaOH + H₂↑) или основный оксид + вода.<br>• <b>Нерастворимые основания:</b> щёлочь + соль → основание↓ + соль: CuSO₄ + 2NaOH → Cu(OH)₂↓ + Na₂SO₄.</p>')
+makeCard('lab','Лабораторный опыт 1 · Получение нерастворимого основания',null,'<p>К раствору соли меди(II) добавь раствор щёлочи — выпадает <b>голубой осадок</b> Cu(OH)₂.</p><div class="tt-row"><div><div style="text-align:center;color:#0891b2">'+tube({color:'#a5f3fc',precipitate:'#22d3ee',label:'Cu(OH)₂'})+'</div><div class="tt-cap">Голубой осадок Cu(OH)₂↓</div></div></div><p>CuSO₄ + 2NaOH → Cu(OH)₂↓ + Na₂SO₄</p>')
+makeCard('lab','Практическая работа 2 · Реакция нейтрализации',null,'<p>К щёлочи с фенолфталеином (малиновый) приливай кислоту — окраска исчезает в точке нейтрализации.</p>'+wgt('Двигай pH: исчезновение малиновой окраски','<div id="c-pr2-ind"></div>'))
+rememberBox(['Cu(OH)₂ — голубой, Fe(OH)₃ — бурый, Fe(OH)₂ — зеленоватый.','Нерастворимое основание получают только из щёлочи + соль.'])
+qList(['Как получить Fe(OH)₃ из соли железа(III)?','Что наблюдают в точке нейтрализации с фенолфталеином?'])
+secNav('p17','p19')+readButton('p18'); wireReadBtn('p18'); }
function bp19(){ document.getElementById('p19-body').innerHTML =
hero(1,'§ 19 · Глава 1','Соли. Состав и классификация','катион + анион','Соли — продукт встречи кислоты и основания; растворимость определяют по таблице.',['растворимость','классификация'])
+makeCard('theory','Соли','§19','<p><b>Соль</b> — сложное вещество из катионов металла (или NH₄⁺) и анионов кислотного остатка: NaCl, CaCO₃, CuSO₄. Это продукт замещения атомов H в кислоте на металл.</p><p>По составу: <b>средние</b> (NaCl, Na₂SO₄), <b>кислые</b> (NaHCO₃), <b>основные</b> (CuOHCl). Растворимость определяют по <b>таблице растворимости</b>.</p>')
+flag('Таблица растворимости','Кликни катион и анион — узнаешь, растворима ли соль и выпадет ли осадок.','<div id="c-salt-sol"></div>')
+rememberBox(['Все нитраты и соли натрия/калия растворимы.','Нерастворимая соль выпадает в осадок ↓.'])
+qList(['Растворим ли BaSO₄? А CaCO₃?','Из какой кислоты и основания получают Na₂SO₄?'])
+secNav('p18','p20')+readButton('p19'); wireReadBtn('p19'); }
function bp20(){ document.getElementById('p20-body').innerHTML =
hero(2,'§ 20 · Глава 1','Химические свойства солей · Лаб. 2','↓ ↑ H₂O','Реакции ионного обмена и вытеснение металлов из растворов солей.',['РИО','соль + Me','Лаб.2'])
+makeCard('theory','Свойства солей','§20','<p>• <b>Соль + соль</b> → две новые соли (если ↓): AgNO₃ + NaCl → AgCl↓ + NaNO₃.<br>• <b>Соль + щёлочь</b> → новое основание↓ + соль.<br>• <b>Соль + кислота</b> → новая соль + кислота (если ↓↑).<br>• <b>Соль + металл</b>: более активный металл вытесняет менее активный: Fe + CuSO₄ → FeSO₄ + Cu.</p>')
+flag('Таблица растворимости (предскажи осадок)','Проверь, образуется ли осадок в реакции обмена.','<div id="c-salt-sol2"></div>')
+flag('Ряд активности (соль + металл)','Кликни металл — увидишь, какие металлы он вытесняет из растворов солей.','<div id="c-salt-act"></div>')
+makeCard('lab','Лабораторный опыт 2 · Соль + металл',null,'<p>Опусти железный гвоздь в раствор CuSO₄ — он покрывается <b>красным налётом меди</b>, раствор бледнеет. Fe + CuSO₄ → FeSO₄ + Cu.</p>')
+rememberBox(['РИО идёт, если образуется осадок, газ или вода.','Металл вытесняет из соли только менее активный металл.'])
+qList(['Вытеснит ли серебро медь из CuSO₄?','Допиши: BaCl₂ + Na₂SO₄ → ?'])
+secNav('p19','p21')+readButton('p20'); wireReadBtn('p20'); }
function bp21(){ document.getElementById('p21-body').innerHTML =
hero(3,'§ 21 · Глава 1','Получение и применение солей','много способов','Соли получают десятком способов — это перекрёсток всех классов.',['способы','применение'])
+makeCard('theory','Способы получения солей','§21','<p>• кислота + основание (нейтрализация)<br>• кислота + основный оксид<br>• кислота + металл<br>• кислота + соль<br>• щёлочь + кислотный оксид<br>• соль + соль<br>• соль + металл<br>• металл + неметалл (2Na + Cl₂ → 2NaCl)</p><p><b>Применение:</b> NaCl — пища, Na₂CO₃ — стекло и мыло, CaCO₃ — строительство, KNO₃ — удобрения.</p>')
+rememberBox(['Соль — продукт реакции кислоты и основания (или их «родственников»).','Один и тот же продукт можно получить разными способами.'])
+qList(['Назови три способа получить CuSO₄.','Где применяют поваренную соль NaCl?'])
+secNav('p20','p22')+readButton('p21'); wireReadBtn('p21'); }
function bp22(){ document.getElementById('p22-body').innerHTML =
hero(4,'§ 22 · Глава 1','Взаимосвязь классов · ПР 3','генетическая связь','Все классы связаны цепочками превращений — от простого вещества до соли.',['генетика','ПР.3'])
+makeCard('theory','Генетическая связь','§22','<p><b>Ряд металла:</b> металл → основный оксид → основание → соль<br>Na → Na₂O → NaOH → NaCl</p><p><b>Ряд неметалла:</b> неметалл → кислотный оксид → кислота → соль<br>S → SO₃ → H₂SO₄ → Na₂SO₄</p><p>Эти ряды «встречаются» в солях — продукте реакции кислоты и основания.</p>')
+flag('Генетическая карта классов','Кликни по стрелке-переходу — увидишь реакцию-пример. Два ряда (металл и неметалл) сходятся в соли.','<div id="c-genetic"></div>')
+'<div class="insight-box"><div class="insight-title">Цепочка превращений</div><p>Ca → CaO → Ca(OH)₂ → CaCl₂. Попробуй записать уравнение каждого перехода!</p></div>'
+makeCard('lab','Практическая работа 3 · Экспериментальные задачи',null,'<p>По выданным реактивам осуществи цепочку превращений и докажи получение каждого вещества (например, получи из меди — оксид меди, затем соль).</p>')
+rememberBox(['Металл и неметалл — начала двух генетических рядов.','Соль — точка встречи кислотного и основного «миров».'])
+qList(['Запиши цепочку S → SO₂ → … → Na₂SO₃.','Какой класс стоит между оксидом металла и солью?'])
+secNav('p21','p23')+readButton('p22'); wireReadBtn('p22'); }
function bp23(){ document.getElementById('p23-body').innerHTML =
hero(5,'§ 23 · Глава 1','Решение расчётных задач','по уравнениям','Расчёты массы и объёма по уравнениям реакций классов неорганических веществ.',['расчёты','мольные отношения'])
+makeCard('rule','Алгоритм','§23','<ol><li>Записать и уравнять уравнение.</li><li>Найти n известного: n=m/M (или V/Vm).</li><li>По коэффициентам — n искомого.</li><li>Перейти к m=n·M (или V=n·Vm).</li></ol>')
+flag('Пошаговый решатель по уравнению','Выбери задачу и раскрывай решение по шагам.','<div class="fld"><label>Задача</label><select id="c-calc-pick"></select></div><div class="out" id="c-calc-out"></div><div class="fld"><button class="btn" id="c-calc-step">Следующий шаг ▸</button><button class="btn" id="c-calc-all">Показать всё</button></div>')
+rememberBox(['Мольное отношение берут из коэффициентов.','Для газов используют Vm=22,4 л/моль.'])
+secNav('p22','final1')+readButton('p23'); wireReadBtn('p23'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 1','Босс: классы неорганических соединений','оксиды · кислоты · основания · соли','Шесть интегрированных задач на всю главу. Победи босса — получи ачивку «Классы веществ покорены».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>Оксиды</h3><div class="main-f">осн · кисл · амф</div></div><div class="fcard"><h3>Кислоты</h3><div class="main-f">HₓAc, основность</div></div><div class="fcard"><h3>Основания</h3><div class="main-f">Me(OH)ₙ</div></div><div class="fcard highlight"><h3>Соли</h3><div class="main-f">катион + анион</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи ниже — за каждую +5 XP, за полную победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p23',null); }
</script>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 2 · «Периодический закон и периодическая система»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 2 — indigo */
:root{ --pri:#4f46e5; --pri-d:#4338ca; --pri-l:#818cf8; --pri-soft:#e0e7ff; --sec-acc:#4f46e5; --sec-acc-d:#4338ca; --sec-acc-soft:#e0e7ff; }
html.dark{ --bg:#12122b; --card:#1b1b3a; --card-soft:#20204a; --text:#e0e7ff; --muted:#a5a5d8; --border:#2e2e5c; --pri-soft:rgba(79,70,229,.22); --sec-acc-soft:rgba(79,70,229,.22); }
.hdr{background:linear-gradient(110deg,#3730a3 0%,#4f46e5 55%,#818cf8 100%)}
.hdr::before{content:'ГЛАВА 2'}
</style>
<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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch2_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 2</h1>
<div class="hdr-sub">Систематизация элементов, амфотерность, естественные семейства, периодический закон Д. И. Менделеева</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Главный закон химии</h2>
<p>В 1869 году Д. И. Менделеев расположил элементы в порядке возрастания атомной массы — и увидел, что их свойства повторяются периодически. Так родилась периодическая система, по которой можно предсказывать свойства веществ.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p24')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 24</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p24" class="sec"><div class="sec-header"><span class="sec-num">§ 24</span><h2 class="sec-h">Систематизация химических элементов</h2></div><div id="p24-body"></div></section>
<section id="sec-p25" class="sec"><div class="sec-header"><span class="sec-num">§ 25</span><h2 class="sec-h">Понятие об амфотерности · Лаб. 3</h2></div><div id="p25-body"></div></section>
<section id="sec-p26" class="sec"><div class="sec-header"><span class="sec-num">§ 26</span><h2 class="sec-h">Естественные семейства элементов</h2></div><div id="p26-body"></div></section>
<section id="sec-p27" class="sec"><div class="sec-header"><span class="sec-num">§ 27</span><h2 class="sec-h">Периодический закон Д. И. Менделеева</h2></div><div id="p27-body"></div></section>
<section id="sec-p28" class="sec"><div class="sec-header"><span class="sec-num">§ 28</span><h2 class="sec-h">Периодическая система химических элементов</h2></div><div id="p28-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 2 · «Периодический закон и периодическая система» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch2', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch2_progress', achKey:'chemistry8_ch2_ach' };
window.PARAS = [
{id:'p24',num:'§ 24',name:'Систематизация элементов',sub:'Me / неMe'},
{id:'p25',num:'§ 25',name:'Амфотерность · Лаб.3',sub:'Zn(OH)₂'},
{id:'p26',num:'§ 26',name:'Естественные семейства',sub:'щелочные, галогены'},
{id:'p27',num:'§ 27',name:'Периодический закон',sub:'Менделеев, 1869'},
{id:'p28',num:'§ 28',name:'Периодическая система',sub:'период · группа'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 2!', final1_tasks:'Периодический закон освоен!' };
['p24','p25','p26','p27','p28'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p24:{title:'§24 Систематизация',rows:[['Металлы','отдают электроны'],['Неметаллы','принимают электроны'],['Амфотерные','двойственны']]},
p25:{title:'§25 Амфотерность',rows:[['Амфотерный','+ кислота И + щёлочь'],['Примеры','Zn(OH)₂, Al(OH)₃'],['Лаб.3','получение Zn(OH)₂']]},
p26:{title:'§26 Семейства',rows:[['Щелочные','Li, Na, K (гр. I)'],['Щёлочноземельные','Ca, Sr, Ba (гр. II)'],['Галогены','F, Cl, Br, I (гр. VII)'],['Инертные','He, Ne, Ar (гр. VIII)']]},
p27:{title:'§27 ПЗ',rows:[['Менделеев','1869 г.'],['Закон','свойства периодичны'],['Основа','атомная масса → заряд ядра']]},
p28:{title:'§28 ПС',rows:[['Период','строка (17)'],['Группа','столбец (IVIII)'],['№ группы','высшая валентность']]},
final1:{title:'Финал главы 2',rows:[['§§2428','периодический закон'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p24',html:'Металлы (слева и снизу в ПСХЭ) отдают электроны, неметаллы (справа и сверху) — принимают. Между ними — амфотерные элементы.'},
{sec:'p25',html:'Амфотерный гидроксид (Zn(OH)₂, Al(OH)₃) реагирует и с кислотой (как основание), и со щёлочью (как кислота).'},
{sec:'p26',html:'Элементы одной группы образуют естественное семейство со сходными свойствами: щелочные металлы, галогены, инертные газы.'},
{sec:'p27',html:'Периодический закон: свойства элементов периодически зависят от заряда ядра их атомов.'},
{sec:'p28',html:'Номер периода = число электронных слоёв, номер группы = число внешних электронов (высшая валентность).'},
{sec:'final1',html:'Семейства, амфотерность, период и группа — повтори перед боссом.'}
];
window.POOLS = {
p24:[
{q:'Где в ПСХЭ расположены металлы?',opts:['Справа сверху','Слева и снизу','Только в группе VIII','В центре'],a:1,ex:'Металлы — слева и снизу от диагонали B–At.'},
{q:'Неметаллы при реакциях обычно…',opts:['Отдают электроны','Принимают электроны','Не реагируют','Образуют щёлочи'],a:1,ex:'Неметаллы принимают электроны (окислители).'},
{q:'Какой элемент проявляет двойственные (амфотерные) свойства?',opts:['Na','Cl','Zn','He'],a:2,ex:'Цинк — амфотерный элемент.'}
],
p25:[
{q:'Амфотерный гидроксид реагирует…',opts:['Только с кислотой','Только со щёлочью','И с кислотой, и со щёлочью','Ни с чем'],a:2,ex:'В этом и состоит амфотерность.'},
{q:'Zn(OH)₂ + 2HCl → … (Zn(OH)₂ ведёт себя как…)',opts:['Кислота','Основание','Соль','Оксид'],a:1,ex:'С кислотой — как основание: ZnCl₂ + H₂O.'},
{q:'Какого цвета осадок Zn(OH)₂?',opts:['Голубой','Белый','Бурый','Чёрный'],a:1,ex:'Zn(OH)₂ — белый студенистый осадок.'}
],
p26:[
{q:'Щелочные металлы — это элементы группы…',opts:['I','II','VII','VIII'],a:0,ex:'Li, Na, K — главная подгруппа I группы.'},
{q:'Галогены — это…',opts:['Li, Na, K','Be, Mg, Ca','F, Cl, Br, I','He, Ne, Ar'],a:2,ex:'Галогены — VII группа.'},
{q:'Инертные (благородные) газы почти не реагируют, потому что…',opts:['Они тяжёлые','Их внешний слой завершён','Они металлы','У них нет ядра'],a:1,ex:'Завершённый внешний электронный слой — устойчивость.'}
],
p27:[
{q:'В каком году открыт периодический закон?',opts:['1689','1769','1869','1969'],a:2,ex:'Д. И. Менделеев, 1869 год.'},
{q:'В основу первой системы Менделеев положил…',opts:['Заряд ядра','Относительную атомную массу','Число нейтронов','Радиус атома'],a:1,ex:'Тогда расположил по возрастанию атомной массы.'},
{q:'Современная формулировка: свойства зависят от…',opts:['Массы атома','Заряда ядра атома','Цвета вещества','Температуры'],a:1,ex:'Свойства периодичны по заряду ядра.'}
],
p28:[
{q:'Номер периода показывает число…',opts:['Протонов','Электронных слоёв','Нейтронов','Групп'],a:1,ex:'Номер периода = число электронных слоёв.'},
{q:'Номер группы (главной подгруппы) показывает число…',opts:['Нейтронов','Внешних электронов','Слоёв','Изотопов'],a:1,ex:'Число внешних электронов = высшая валентность.'},
{q:'Сколько всего периодов в ПСХЭ?',hint:'строки',unit:'',a:7,ex:'7 периодов.'}
],
final1:[
{q:'Цинк по свойствам — это…',opts:['Типичный металл','Типичный неметалл','Амфотерный элемент','Инертный газ'],a:2,ex:'Zn амфотерен.'},
{q:'К какому семейству относится хлор Cl?',opts:['Щелочные','Галогены','Инертные','ЩЗМ'],a:1,ex:'Cl — галоген (VII группа).'},
{q:'Год открытия периодического закона:',hint:'',unit:'',a:1869,ex:'1869.'},
{q:'Номер группы натрия Na (число внешних электронов):',hint:'I группа',unit:'',a:1,ex:'1 внешний электрон.'},
{q:'Сколько электронных слоёв у элемента 3-го периода?',hint:'= номер периода',unit:'',a:3,ex:'3 слоя.'},
{q:'Сколько внешних электронов у инертных газов (VIII группа)?',hint:'завершённый слой',unit:'',a:8,ex:'8 (у He — 2).'},
{q:'К какому семейству относится калий K?',opts:['Галогены','Щелочные металлы','Инертные','ЩЗМ'],a:1,ex:'K — щелочной металл (I группа).'} /*U5-extra*/
]
};
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function wgt(title, inner){ return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> '+title+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
window.BUILDERS = { p24:bp24, p25:bp25, p26:bp26, p27:bp27, p28:bp28, final1:bfinal };
function bp24(){ document.getElementById('p24-body').innerHTML =
hero(3,'§ 24 · Глава 2','Систематизация химических элементов','Me · неMe','Первые попытки навести порядок среди элементов: разделение на металлы и неметаллы.',['металлы','неметаллы','амфотерные'])
+makeCard('theory','Металлы и неметаллы','§24','<p>Элементы делят на <b>металлы</b> (отдают электроны, образуют основные оксиды и основания) и <b>неметаллы</b> (принимают электроны, образуют кислотные оксиды и кислоты). Граница условна — у диагонали B–Si–As–Te стоят <b>амфотерные</b> элементы (Be, Al, Zn).</p>')
+flag('Интерактивная ПСХЭ: металлы и неметаллы','Нажимай кнопки-режимы и кликай по элементам — увидишь, как распределены металлы, неметаллы и металлоиды.','<div id="c-pt-metals"></div>')
+rememberBox(['Металлы — слева и снизу, неметаллы — справа и сверху.','Амфотерные элементы — у диагонали.'])
+qList(['Назови три металла и три неметалла.','Почему граница между ними условна?'])
+secNav(null,'p25')+readButton('p24'); wireReadBtn('p24'); }
function bp25(){ document.getElementById('p25-body').innerHTML =
hero(9,'§ 25 · Глава 2','Понятие об амфотерности · Лаб. 3','Zn(OH)₂','Некоторые гидроксиды ведут себя двойственно — реагируют и с кислотами, и со щелочами.',['амфотерность','Zn(OH)₂','Лаб.3'])
+makeCard('theory','Амфотерность','§25','<p><b>Амфотерные</b> гидроксиды (Zn(OH)₂, Al(OH)₃, Be(OH)₂) проявляют свойства и оснований, и кислот:</p><p>как <b>основание</b>: Zn(OH)₂ + 2HCl → ZnCl₂ + 2H₂O<br>как <b>кислота</b>: Zn(OH)₂ + 2NaOH → Na₂[Zn(OH)₄]</p>')
+flag('Опыт: амфотерность Zn(OH)₂','Добавь к осадку кислоту или щёлочь — в обоих случаях он растворяется.','<div id="c-amph"></div>')
+makeCard('lab','Лабораторный опыт 3 · Получение гидроксида цинка',null,'<p>К раствору соли цинка прилей немного щёлочи — выпадет <b>белый осадок</b> Zn(OH)₂. Раздели на две пробирки: в одну добавь кислоту, в другую — избыток щёлочи. Осадок растворяется в обоих случаях — это и есть амфотерность.</p><p>ZnSO₄ + 2NaOH → Zn(OH)₂↓ + Na₂SO₄</p>')
+rememberBox(['Амфотерный = и основание, и кислота одновременно.','Zn(OH)₂ и Al(OH)₃ — главные примеры в 8 классе.'])
+qList(['Допиши: Al(OH)₃ + NaOH → ?','Как доказать амфотерность гидроксида цинка?'])
+secNav('p24','p26')+readButton('p25'); wireReadBtn('p25'); }
function bp26(){ document.getElementById('p26-body').innerHTML =
hero(4,'§ 26 · Глава 2','Естественные семейства элементов','группы','Элементы со схожими свойствами объединяются в семейства — это подсказало структуру системы.',['щелочные','галогены','инертные'])
+makeCard('theory','Семейства элементов','§26','<p>Элементы со сходными свойствами образуют <b>естественные семейства</b>:</p><ul><li><b>Щелочные металлы</b> (Li, Na, K, Rb, Cs) — очень активные, бурно реагируют с водой.</li><li><b>Щёлочноземельные</b> (Ca, Sr, Ba) — активные металлы.</li><li><b>Галогены</b> (F, Cl, Br, I) — активные неметаллы.</li><li><b>Инертные газы</b> (He, Ne, Ar) — почти не реагируют.</li></ul>')
+flag('ПСХЭ: подсветка семейств','Нажми кнопку семейства — соответствующие элементы подсветятся в системе.','<div id="c-pt-fam"></div>')
+rememberBox(['Элементы одной группы — одно семейство.','Активность щелочных металлов растёт вниз по группе, галогенов — убывает.'])
+qList(['Назови все галогены.','Почему инертные газы малоактивны?'])
+secNav('p25','p27')+readButton('p26'); wireReadBtn('p26'); }
function bp27(){ document.getElementById('p27-body').innerHTML =
hero(5,'§ 27 · Глава 2','Периодический закон Д. И. Менделеева','1869','Открытие, изменившее химию: свойства элементов повторяются периодически.',['Менделеев','периодичность'])
+makeCard('theory','Периодический закон','§27','<p>В 1869 году <b>Д. И. Менделеев</b> расположил элементы по возрастанию относительной атомной массы и заметил: свойства повторяются через определённые промежутки — <b>периодически</b>.</p><div class="def-box">Современная формулировка: <b>свойства химических элементов и их соединений находятся в периодической зависимости от заряда ядра атомов</b>.</div><p>Менделеев даже предсказал свойства ещё не открытых элементов (галлий, германий) — и оказался прав!</p>')
+'<div class="insight-box"><div class="insight-title">Почему «периодический»</div><p>Двигаясь по периоду слева направо, металлические свойства ослабевают, неметаллические — усиливаются. В начале следующего периода всё повторяется снова.</p></div>'
+rememberBox(['Основа закона — заряд ядра (порядковый номер), а не масса.','Менделеев предсказал неоткрытые элементы по «пустым клеткам».'])
+qList(['Сформулируй периодический закон.','Что предсказал Менделеев благодаря своей системе?'])
+secNav('p26','p28')+readButton('p27'); wireReadBtn('p27'); }
function bp28(){ document.getElementById('p28-body').innerHTML =
hero(6,'§ 28 · Глава 2','Периодическая система химических элементов','период · группа','Графическое выражение закона: таблица из периодов и групп, по которой «читают» элемент.',['период','группа','подгруппа'])
+makeCard('theory','Структура системы','§28','<p><b>Период</b> — горизонтальная строка (всего 7). Номер периода = число электронных слоёв в атоме.<br><b>Группа</b> — вертикальный столбец (I–VIII). Номер группы = число внешних электронов = высшая валентность.<br>Группы делятся на <b>главную</b> (А) и <b>побочную</b> (Б) подгруппы.</p>')
+flag('ПСХЭ: периоды и группы','Подсвети период или группу — увидишь, как устроена система.','<div id="c-pt-struct"></div>')
+rememberBox(['Номер периода = число электронных слоёв.','Номер группы = число внешних электронов.','7 периодов, 8 групп (главные подгруппы).'])
+qList(['Сколько внешних электронов у элементов II группы?','В каком периоде элемент с тремя электронными слоями?'])
+secNav('p27','final1')+readButton('p28'); wireReadBtn('p28'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 2','Босс: периодический закон','семейства · период · группа','Пять интегрированных задач по всей главе. Победи босса — ачивка «Периодический закон освоен».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>Период</h3><div class="main-f">= число слоёв</div></div><div class="fcard"><h3>Группа</h3><div class="main-f">= внешние e⁻</div></div><div class="fcard"><h3>Семейства</h3><div class="main-f">щелочные · галогены</div></div><div class="fcard highlight"><h3>Закон</h3><div class="main-f">периодичность по Z</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи — за каждую +5 XP, за победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p28',null); }
</script>
</body>
</html>
+233
View File
@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 3 · «Строение атома»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 3 — blue */
:root{ --pri:#2563eb; --pri-d:#1d4ed8; --pri-l:#60a5fa; --pri-soft:#dbeafe; --sec-acc:#2563eb; --sec-acc-d:#1d4ed8; --sec-acc-soft:#dbeafe; }
html.dark{ --bg:#0a1428; --card:#102137; --card-soft:#13294a; --text:#dbeafe; --muted:#93c5fd; --border:#1e3a5f; --pri-soft:rgba(37,99,235,.22); --sec-acc-soft:rgba(37,99,235,.22); }
.hdr{background:linear-gradient(110deg,#1e3a8a 0%,#2563eb 55%,#60a5fa 100%)}
.hdr::before{content:'ГЛАВА 3'}
</style>
<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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch3_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 3</h1>
<div class="hdr-sub">Строение атома, нуклиды и изотопы, электронные облака и орбитали, электронные оболочки, периодичность</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Что внутри атома</h2>
<p>Атом неделим химически, но состоит из ядра (протоны и нейтроны) и движущихся вокруг электронов. Именно строение электронных оболочек объясняет, почему элементы ведут себя так, а не иначе — и почему работает периодический закон.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p29')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 29</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p29" class="sec"><div class="sec-header"><span class="sec-num">§ 29</span><h2 class="sec-h">Строение атома. Атомный номер</h2></div><div id="p29-body"></div></section>
<section id="sec-p30" class="sec"><div class="sec-header"><span class="sec-num">§ 30</span><h2 class="sec-h">Массовое число атома. Нуклиды</h2></div><div id="p30-body"></div></section>
<section id="sec-p31" class="sec"><div class="sec-header"><span class="sec-num">§ 31</span><h2 class="sec-h">Изотопы. Явление радиоактивности</h2></div><div id="p31-body"></div></section>
<section id="sec-p32" class="sec"><div class="sec-header"><span class="sec-num">§ 32</span><h2 class="sec-h">Состояние электронов. Электронное облако. Орбиталь</h2></div><div id="p32-body"></div></section>
<section id="sec-p33" class="sec"><div class="sec-header"><span class="sec-num">§ 33</span><h2 class="sec-h">Строение электронных оболочек атомов</h2></div><div id="p33-body"></div></section>
<section id="sec-p34" class="sec"><div class="sec-header"><span class="sec-num">§ 34</span><h2 class="sec-h">Периодичность изменения свойств атомов</h2></div><div id="p34-body"></div></section>
<section id="sec-p35" class="sec"><div class="sec-header"><span class="sec-num">§ 35</span><h2 class="sec-h">Характеристика элемента по положению в ПС</h2></div><div id="p35-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 3 · «Строение атома» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch3', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch3_progress', achKey:'chemistry8_ch3_ach' };
window.PARAS = [
{id:'p29',num:'§ 29',name:'Строение атома',sub:'ядро + e⁻'},
{id:'p30',num:'§ 30',name:'Массовое число. Нуклиды',sub:'A = Z + N'},
{id:'p31',num:'§ 31',name:'Изотопы. Радиоактивность',sub:'тот же Z, разный N'},
{id:'p32',num:'§ 32',name:'Электронное облако. Орбиталь',sub:'s, p, d'},
{id:'p33',num:'§ 33',name:'Электронные оболочки',sub:'слои, 2n²'},
{id:'p34',num:'§ 34',name:'Периодичность свойств',sub:'тренды'},
{id:'p35',num:'§ 35',name:'Характеристика элемента',sub:'паспорт'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 3!', final1_tasks:'Строение атома освоено!' };
['p29','p30','p31','p32','p33','p34','p35'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p29:{title:'§29 Атом',rows:[['Ядро','протоны p⁺ + нейтроны n⁰'],['Электроны','e⁻ вокруг ядра'],['Z','= число протоков = заряд ядра'],['Атом','нейтрален: p⁺ = e⁻']]},
p30:{title:'§30 Нуклиды',rows:[['A','массовое число'],['A = Z + N',''],['Нуклид','атом с данными Z и N']]},
p31:{title:'§31 Изотопы',rows:[['Изотопы','один Z, разный N'],['Пример','³⁵Cl и ³⁷Cl'],['A_r','среднее по изотопам']]},
p32:{title:'§32 Орбиталь',rows:[['Облако','область вероятного нахождения e⁻'],['s','сфера'],['p','гантель']]},
p33:{title:'§33 Оболочки',rows:[['Слой','уровень энергии'],['Ёмкость','2n² электронов'],['Внешние e⁻','определяют свойства']]},
p34:{title:'§34 Периодичность',rows:[['По периоду →','радиус ↓, неметалл ↑'],['По группе ↓','радиус ↑, металл ↑']]},
p35:{title:'§35 Характеристика',rows:[['Положение','период, группа'],['Строение','слои электронов'],['Свойства','Me/неMe, валентность']]},
final1:{title:'Финал главы 3',rows:[['§§2935','строение атома'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p29',html:'Атом состоит из ядра (протоны + нейтроны) и электронов. Число протонов = заряду ядра = порядковому номеру Z.'},
{sec:'p30',html:'Массовое число A = число протонов + число нейтронов = Z + N.'},
{sec:'p31',html:'Изотопы — атомы одного элемента с разным числом нейтронов (одинаковый Z, разный A).'},
{sec:'p32',html:'Электронное облако (орбиталь) — область, где электрон бывает чаще всего. s-облако — сфера, p-облако — гантель.'},
{sec:'p33',html:'Электроны располагаются по слоям; ёмкость слоя — 2n² электронов. Внешний слой определяет химические свойства.'},
{sec:'p34',html:'По периоду слева направо радиус атома уменьшается; вниз по группе — растёт.'},
{sec:'p35',html:'Зная положение элемента, можно описать строение его атома и предсказать свойства.'},
{sec:'final1',html:'Z, A=Z+N, изотопы, слои электронов, периодичность — повтори перед боссом.'}
];
window.POOLS = {
p29:[
{q:'Из чего состоит ядро атома?',opts:['Из электронов','Из протонов и нейтронов','Только из протонов','Из молекул'],a:1,ex:'Ядро = протоны p⁺ + нейтроны n⁰.'},
{q:'Чему равно число протонов в атоме?',opts:['Массовому числу','Порядковому номеру Z','Числу нейтронов','Номеру периода'],a:1,ex:'Число протонов = Z (заряд ядра).'},
{q:'Сколько электронов в нейтральном атоме натрия (Z=11)?',hint:'= числу протонов',unit:'',a:11,ex:'Атом нейтрален: e⁻ = p⁺ = 11.'}
],
p30:[
{q:'Массовое число A равно…',opts:['Z N','Z + N','N Z','Z · N'],a:1,ex:'A = Z + N.'},
{q:'В атоме углерода Z=6, A=12. Сколько нейтронов?',hint:'N = A Z',unit:'',a:6,ex:'12 6 = 6.'},
{q:'В атоме ²³Na (Z=11) число нейтронов равно…',hint:'23 11',unit:'',a:12,ex:'N = 23 11 = 12.'}
],
p31:[
{q:'Изотопы — это атомы одного элемента с разным числом…',opts:['Протонов','Электронов','Нейтронов','Слоёв'],a:2,ex:'Разное число нейтронов → разное A.'},
{q:'У изотопов одинаковое число…',opts:['Нейтронов','Протонов (Z)','Массовое число','—'],a:1,ex:'Одинаковый Z (один элемент).'},
{q:'Хлор: ³⁵Cl (75%) и ³⁷Cl (25%). Средняя A_r ≈',hint:'(35·75+37·25)/100',unit:'',a:35.5,tol:0.02,ex:'35,5.'}
],
p32:[
{q:'Электронное облако (орбиталь) — это…',opts:['Точная орбита электрона','Область наиболее вероятного нахождения электрона','Ядро атома','Слой нейтронов'],a:1,ex:'Орбиталь — область вероятного нахождения e⁻.'},
{q:'Какую форму имеет s-орбиталь?',opts:['Гантель','Сфера','Кольцо','Куб'],a:1,ex:'s-облако — сферическое.'},
{q:'Какую форму имеет p-орбиталь?',opts:['Сфера','Гантель','Спираль','Плоскость'],a:1,ex:'p-облако — гантель (объёмная восьмёрка).'}
],
p33:[
{q:'Сколько электронов максимально на первом слое (n=1)?',hint:'2n², n=1',unit:'',a:2,ex:'2·1² = 2.'},
{q:'Сколько электронов максимально на втором слое (n=2)?',hint:'2n², n=2',unit:'',a:8,ex:'2·2² = 8.'},
{q:'Распределение электронов в атоме натрия (Z=11):',opts:['2,9','2,8,1','8,3','2,8,8'],a:1,ex:'Na: 2 ) 8 ) 1.'},
{q:'Что определяет химические свойства атома?',opts:['Число нейтронов','Внешние электроны','Масса ядра','Цвет'],a:1,ex:'Электроны внешнего слоя.'}
],
p34:[
{q:'По периоду слева направо радиус атома…',opts:['Растёт','Уменьшается','Не меняется','Удваивается'],a:1,ex:'Заряд ядра растёт, притягивает сильнее → радиус ↓.'},
{q:'Вниз по группе металлические свойства…',opts:['Усиливаются','Ослабевают','Исчезают','Не меняются'],a:0,ex:'Радиус растёт, e⁻ легче отдаётся → металл ↑.'},
{q:'Самый активный неметалл (правый верх ПСХЭ):',opts:['Натрий','Фтор','Железо','Гелий'],a:1,ex:'Фтор — самый активный неметалл.'}
],
p35:[
{q:'Что можно определить по положению элемента в ПСХЭ?',opts:['Только цвет','Строение атома и свойства','Только массу','Ничего'],a:1,ex:'Период, группа → строение и свойства.'},
{q:'Элемент 3-го периода, II группы — это…',opts:['Na','Mg','Al','Ca'],a:1,ex:'Mg: период 3, группа II.'},
{q:'Сколько внешних электронов у элемента VII группы?',hint:'= номер группы',unit:'',a:7,ex:'7 внешних электронов.'}
],
final1:[
{q:'Сколько нейтронов в ³⁹K (Z=19)?',hint:'39 19',unit:'',a:20,ex:'N = 20.'},
{q:'Изотопы отличаются числом…',opts:['Протонов','Электронов','Нейтронов','Групп'],a:2,ex:'Числом нейтронов.'},
{q:'Максимум электронов на 3-м слое (2n², n=3):',hint:'2·9',unit:'',a:18,ex:'18.'},
{q:'Распределение e⁻ в атоме кислорода (Z=8):',opts:['2,6','2,8','6,2','8'],a:0,ex:'O: 2 ) 6.'},
{q:'По периоду слева направо неметаллические свойства…',opts:['Ослабевают','Усиливаются','Не меняются','Исчезают'],a:1,ex:'Усиливаются.'},
{q:'Число внешних электронов у элемента I группы:',hint:'',unit:'',a:1,ex:'1.'},
{q:'Сколько протонов в атоме серы (Z=16)?',unit:'',a:16,ex:'Число протонов = Z = 16.'},
{q:'Распределение электронов у магния (Z=12):',opts:['2, 8, 2','2, 10','8, 4','2, 8, 8'],a:0,ex:'Mg: 2 ) 8 ) 2.'} /*U5-extra*/
]
};
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function wgt(title, inner){ return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="9" fill="none"/></svg> '+title+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
window.BUILDERS = { p29:bp29, p30:bp30, p31:bp31, p32:bp32, p33:bp33, p34:bp34, p35:bp35, final1:bfinal };
function bp29(){ document.getElementById('p29-body').innerHTML =
hero(4,'§ 29 · Глава 3','Строение атома. Атомный номер','Z = p⁺ = e⁻','Атом неделим химически, но имеет сложное строение: крошечное ядро и облако электронов.',['ядро','электроны','Z'])
+makeCard('theory','Состав атома','§29','<p>Атом состоит из положительно заряженного <b>ядра</b> (протоны p⁺ и нейтроны n⁰) и движущихся вокруг него <b>электронов</b> e⁻. Почти вся масса сосредоточена в ядре.</p><div class="def-box">Число протонов в ядре = <b>заряд ядра</b> = <b>порядковый номер Z</b>. Атом электронейтрален: число электронов равно числу протонов.</div>')
+flag('Модель атома: двигай Z','Меняй порядковый номер — собирается ядро и электронные слои элемента.','<div id="c-atom"></div>')
+rememberBox(['Z = число протонов = число электронов в нейтральном атоме.','Масса атома сосредоточена в ядре.'])
+qList(['Сколько электронов в атоме серы (Z=16)?','Чем определяется заряд ядра?'])
+secNav(null,'p30')+readButton('p29'); wireReadBtn('p29'); }
function bp30(){ document.getElementById('p30-body').innerHTML =
hero(5,'§ 30 · Глава 3','Массовое число атома. Нуклиды','A = Z + N','Как считают массу ядра и что такое нуклид — конкретный сорт атома.',['A','нуклид','нейтроны'])
+makeCard('theory','Массовое число','§30','<div class="def-box"><b>Массовое число</b> A = число протонов + число нейтронов = Z + N. Отсюда число нейтронов N = A Z.</div><p><b>Нуклид</b> — вид атомов с определённым числом протонов и нейтронов (например, ¹²C, ²³Na).</p>')
+wgt('Калькулятор нуклида: A = Z + N','<div id="c-nuclide"></div>')
+rememberBox(['A — целое число (масса в а.е.м. ≈ A).','N = A − Z; протонов всегда Z.'])
+qList(['Сколько нейтронов в ²⁷Al (Z=13)?','Чем нуклид отличается от элемента?'])
+secNav('p29','p31')+readButton('p30'); wireReadBtn('p30'); }
function bp31(){ document.getElementById('p31-body').innerHTML =
hero(6,'§ 31 · Глава 3','Изотопы. Явление радиоактивности','³⁵Cl, ³⁷Cl','Почему атомная масса хлора — 35,5, и откуда берётся радиоактивность.',['изотопы','A_r','распад'])
+makeCard('theory','Изотопы','§31','<p><b>Изотопы</b> — атомы одного элемента с <b>одинаковым числом протонов</b> (Z), но <b>разным числом нейтронов</b> (разное A). Относительная атомная масса элемента — среднее по природной смеси изотопов.</p><p>Ядра некоторых изотопов самопроизвольно распадаются, испуская излучение, — это <b>радиоактивность</b>.</p>')
+wgt('Средняя A_r по изотопам','<div id="c-iso"></div>')
+rememberBox(['Изотопы: один Z, разный N (и A).','A_r хлора = 35,5 — среднее по ³⁵Cl и ³⁷Cl.'])
+qList(['Чем похожи и чем отличаются изотопы?','Почему A_r многих элементов — дробные?'])
+secNav('p30','p32')+readButton('p31'); wireReadBtn('p31'); }
function bp32(){ document.getElementById('p32-body').innerHTML =
hero(7,'§ 32 · Глава 3','Состояние электронов. Электронное облако. Орбиталь','s, p, d','Электрон — не шарик на орбите, а «облако вероятности». Его форму описывает орбиталь.',['облако','s','p'])
+makeCard('theory','Электронное облако','§32','<p>Электрон в атоме движется не по чёткой орбите, а образует <b>электронное облако</b> — область, где он бывает чаще всего. <b>Орбиталь</b> — это форма такого облака.</p><p><b>s-орбиталь</b> имеет форму сферы, <b>p-орбиталь</b> — форму гантели (объёмной восьмёрки). Есть и более сложные d-орбитали.</p>')
+'<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> Формы электронных облаков</div><div class="orb-row"><div class="orb-item"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="34" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="2"/><circle cx="50" cy="50" r="3" fill="var(--pri)"/></svg><div class="orb-lab">s — сфера</div></div><div class="orb-item"><svg viewBox="0 0 100 100"><ellipse cx="50" cy="30" rx="16" ry="24" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="2"/><ellipse cx="50" cy="70" rx="16" ry="24" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="2"/><circle cx="50" cy="50" r="3" fill="var(--pri)"/></svg><div class="orb-lab">p — гантель</div></div></div></div>'
+rememberBox(['Орбиталь — форма электронного облака, а не орбита.','s — сфера, p — гантель.'])
+qList(['Какую форму имеет s-облако? А p-облако?','Чем облако отличается от орбиты планеты?'])
+secNav('p31','p33')+readButton('p32'); wireReadBtn('p32'); }
function bp33(){ document.getElementById('p33-body').innerHTML =
hero(8,'§ 33 · Глава 3','Строение электронных оболочек атомов','2n²','Как электроны распределяются по слоям и почему именно внешний слой решает всё.',['слои','2n²','конфигурация'])
+makeCard('theory','Электронные слои','§33','<p>Электроны располагаются вокруг ядра по <b>слоям</b> (энергетическим уровням). Максимальная ёмкость слоя — <b>2n²</b> электронов: 1-й слой — 2, 2-й — 8, 3-й — 18.</p><p>Слои заполняются от ближнего к ядру. <b>Внешний слой</b> (валентные электроны) определяет химические свойства элемента. Например, натрий: 2 ) 8 ) 1 — один внешний электрон, поэтому активный металл.</p>')
+flag('Конструктор электронных оболочек','Двигай Z — увидишь распределение электронов по слоям (2 ) 8 ) 1 …).','<div id="c-shells"></div>')
+rememberBox(['Ёмкость слоя — 2n² электронов.','Свойства определяет внешний (последний) слой.'])
+qList(['Запиши распределение электронов для алюминия (Z=13).','Сколько внешних электронов у кислорода?'])
+secNav('p32','p34')+readButton('p33'); wireReadBtn('p33'); }
function bp34(){ document.getElementById('p34-body').innerHTML =
hero(9,'§ 34 · Глава 3','Периодичность изменения свойств атомов','тренды','Свойства атомов меняются закономерно — по периодам и группам.',['радиус','металличность'])
+makeCard('theory','Закономерности','§34','<p><b>По периоду</b> (слева направо): заряд ядра растёт, радиус атома уменьшается, металлические свойства ослабевают, неметаллические — усиливаются.<br><b>По группе</b> (сверху вниз): число слоёв растёт, радиус увеличивается, металлические свойства усиливаются.</p>')
+flag('ПСХЭ: тренды свойств','Подсвети период или группу — увидишь направление изменения свойств.','<div id="c-trend"></div>')
+rememberBox(['По периоду → неметалл усиливается; по группе ↓ металл усиливается.','Самый активный неметалл — фтор (правый верх).'])
+qList(['Что активнее: Na или K? Почему?','Как меняется радиус по периоду?'])
+secNav('p33','p35')+readButton('p34'); wireReadBtn('p34'); }
function bp35(){ document.getElementById('p35-body').innerHTML =
hero(1,'§ 35 · Глава 3','Характеристика элемента по положению','паспорт элемента','Зная клетку элемента в системе, можно «прочитать» строение его атома и свойства.',['период','группа','паспорт'])
+makeCard('rule','Алгоритм характеристики','§35','<ol><li>Найти Z, период и группу.</li><li>Определить состав атома: протоны = Z, электроны = Z.</li><li>Распределить электроны по слоям (число слоёв = период, внешних e⁻ = группа).</li><li>Сделать вывод: металл/неметалл, валентность, типичные соединения.</li></ol>')
+flag('Генератор «паспорта элемента»','Кликни любой элемент — получишь его полную характеристику по положению в ПСХЭ.','<div id="c-passport"></div>')
+rememberBox(['Период = число слоёв, группа = число внешних электронов.','По положению можно предсказать свойства элемента.'])
+qList(['Дай характеристику атома магния по плану.','Сколько слоёв и внешних электронов у хлора?'])
+secNav('p34','final1')+readButton('p35'); wireReadBtn('p35'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 3','Босс: строение атома','Z · A=Z+N · слои · периодичность','Шесть интегрированных задач по всей главе. Победи босса — ачивка «Строение атома освоено».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>Состав</h3><div class="main-f">ядро + e⁻</div></div><div class="fcard"><h3>Масса</h3><div class="main-f">A = Z + N</div></div><div class="fcard"><h3>Слои</h3><div class="main-f">2n² электронов</div></div><div class="fcard highlight"><h3>Тренд</h3><div class="main-f">период → неметалл↑</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи — за каждую +5 XP, за победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p35',null); }
</script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 4 · «Химическая связь»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 4 — green */
:root{ --pri:#059669; --pri-d:#047857; --pri-l:#34d399; --pri-soft:#d1fae5; --sec-acc:#059669; --sec-acc-d:#047857; --sec-acc-soft:#d1fae5; }
html.dark{ --bg:#0a1a12; --card:#10271c; --card-soft:#143524; --text:#d1fae5; --muted:#6ee7b7; --border:#1d4634; --pri-soft:rgba(5,150,105,.22); --sec-acc-soft:rgba(5,150,105,.22); }
.hdr{background:linear-gradient(110deg,#064e3b 0%,#059669 55%,#34d399 100%)}
.hdr::before{content:'ГЛАВА 4'}
</style>
<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 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>
<script src="/js/chem8_mol.js" defer></script>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch4_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 4</h1>
<div class="hdr-sub">Природа связи, ковалентная (полярная и неполярная), ионная и металлическая связь, кристаллические решётки</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Почему атомы держатся вместе</h2>
<p>Атомы соединяются, чтобы завершить внешний электронный слой и стать устойчивее. В зависимости от того, как именно они «делят» электроны, возникают разные типы химической связи — а от них зависят свойства веществ.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p36')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 36</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p36" class="sec"><div class="sec-header"><span class="sec-num">§ 36</span><h2 class="sec-h">Природа химической связи</h2></div><div id="p36-body"></div></section>
<section id="sec-p37" class="sec"><div class="sec-header"><span class="sec-num">§ 37</span><h2 class="sec-h">Ковалентная связь</h2></div><div id="p37-body"></div></section>
<section id="sec-p38" class="sec"><div class="sec-header"><span class="sec-num">§ 38</span><h2 class="sec-h">Полярная и неполярная связь. Электроотрицательность · Лаб. 4</h2></div><div id="p38-body"></div></section>
<section id="sec-p39" class="sec"><div class="sec-header"><span class="sec-num">§ 39</span><h2 class="sec-h">Ионная связь</h2></div><div id="p39-body"></div></section>
<section id="sec-p40" class="sec"><div class="sec-header"><span class="sec-num">§ 40</span><h2 class="sec-h">Металлическая связь. Межмолекулярное взаимодействие</h2></div><div id="p40-body"></div></section>
<section id="sec-p41" class="sec"><div class="sec-header"><span class="sec-num">§ 41</span><h2 class="sec-h">Кристаллическое состояние вещества</h2></div><div id="p41-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 4 · «Химическая связь» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch4', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch4_progress', achKey:'chemistry8_ch4_ach' };
window.PARAS = [
{id:'p36',num:'§ 36',name:'Природа связи',sub:'октет · энергия'},
{id:'p37',num:'§ 37',name:'Ковалентная связь',sub:'общие e-пары'},
{id:'p38',num:'§ 38',name:'Полярность · ЭО · Лаб.4',sub:'ΔЭО → тип'},
{id:'p39',num:'§ 39',name:'Ионная связь',sub:'передача e⁻'},
{id:'p40',num:'§ 40',name:'Металлическая связь',sub:'электронный газ'},
{id:'p41',num:'§ 41',name:'Кристаллические решётки',sub:'4 типа'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 4!', final1_tasks:'Химическая связь освоена!' };
['p36','p37','p38','p39','p40','p41'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p36:{title:'§36 Природа',rows:[['Причина','завершить внешний слой'],['Октет','8 e⁻ — устойчиво'],['Энергия','при связи выделяется']]},
p37:{title:'§37 Ковалентная',rows:[['Связь','общие электронные пары'],['Между','неметаллами'],['Кратность','одинарная/двойная/тройная']]},
p38:{title:'§38 Полярность',rows:[['Неполярная','ΔЭО ≈ 0 (H₂, Cl₂)'],['Полярная','ΔЭО ≠ 0 (HCl, H₂O)'],['ЭО','тяга к электронам']]},
p39:{title:'§39 Ионная',rows:[['Связь','передача e⁻'],['Между','Me и неMe'],['Ионы','катион + анион']]},
p40:{title:'§40 Металлическая',rows:[['Связь','ион-остовы + e-газ'],['Свойства','ковкость, блеск, ток'],['Межмолек.','слабое притяжение']]},
p41:{title:'§41 Решётки',rows:[['Ионная','соли — тугоплавкие'],['Атомная','алмаз — твёрдый'],['Молекулярная','лёд — летучий'],['Металлическая','металлы']]},
final1:{title:'Финал главы 4',rows:[['§§3641','химическая связь'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p36',html:'Атомы соединяются, чтобы завершить внешний электронный слой (правило октета) и понизить энергию — стать устойчивее.'},
{sec:'p37',html:'Ковалентная связь — общие электронные пары между атомами (обычно неметаллов).'},
{sec:'p38',html:'Если ΔЭО ≈ 0 — связь неполярная; если ЭО разная — полярная (пара смещена к более ЭО атому).'},
{sec:'p39',html:'Ионная связь — полная передача электронов от металла к неметаллу; образуются ионы.'},
{sec:'p40',html:'Металлическая связь — положительные ион-остовы в «электронном газе» из общих электронов.'},
{sec:'p41',html:'Тип кристаллической решётки определяет свойства: ионная — тугоплавкая, молекулярная — летучая.'},
{sec:'final1',html:'Ковалентная, ионная, металлическая связь и решётки — повтори перед боссом.'}
];
window.POOLS = {
p36:[
{q:'Почему атомы образуют химические связи?',opts:['Случайно','Чтобы завершить внешний слой и стать устойчивее','Чтобы стать тяжелее','Из-за нагревания'],a:1,ex:'Стремление к завершённому внешнему слою (октету).'},
{q:'Завершённый внешний слой содержит (для большинства) … электронов',opts:['2','4','8','18'],a:2,ex:'Октет — 8 электронов (у He — 2).'},
{q:'При образовании связи энергия…',opts:['Поглощается','Выделяется','Не меняется','Исчезает'],a:1,ex:'Связанное состояние устойчивее → энергия выделяется.'}
],
p37:[
{q:'Ковалентная связь образуется за счёт…',opts:['Передачи электронов','Общих электронных пар','Притяжения ионов','Электронного газа'],a:1,ex:'Общие электронные пары.'},
{q:'Между какими атомами обычно ковалентная связь?',opts:['Металл + металл','Металл + неметалл','Неметалл + неметалл','Ион + ион'],a:2,ex:'Между атомами неметаллов.'},
{q:'Сколько общих пар в молекуле N₂ (тройная связь)?',hint:'тройная',unit:'',a:3,ex:'Тройная связь — 3 общие пары.'}
],
p38:[
{q:'Связь в молекуле H₂ (одинаковые атомы) — это…',opts:['Полярная','Неполярная','Ионная','Металлическая'],a:1,ex:'ΔЭО = 0 → неполярная.'},
{q:'Связь в молекуле HCl — это…',opts:['Неполярная','Полярная ковалентная','Ионная','Металлическая'],a:1,ex:'ΔЭО ≈ 0,9 → полярная ковалентная.'},
{q:'Электроотрицательность — это…',opts:['Масса атома','Способность притягивать общие электроны','Заряд ядра','Число нейтронов'],a:1,ex:'ЭО — мера притяжения общих электронов.'}
],
p39:[
{q:'Ионная связь образуется за счёт…',opts:['Общих пар','Полной передачи электронов','Электронного газа','Нейтронов'],a:1,ex:'Электрон полностью переходит к неметаллу.'},
{q:'Связь в NaCl — это…',opts:['Ковалентная','Ионная','Металлическая','Водородная'],a:1,ex:'Na (металл) + Cl (неметалл), ΔЭО велика → ионная.'},
{q:'Натрий в NaCl превращается в…',opts:['Na⁻','Na⁺','Na₂','атом Na'],a:1,ex:'Отдаёт электрон → катион Na⁺.'}
],
p40:[
{q:'Металлическую связь образует…',opts:['Передача e⁻','Общие пары','Ион-остовы и свободные электроны','Притяжение молекул'],a:2,ex:'«Электронный газ» связывает ион-остовы.'},
{q:'Чем объясняется электропроводность металлов?',opts:['Ионами','Свободными электронами','Нейтронами','Молекулами'],a:1,ex:'Свободные электроны переносят заряд.'},
{q:'Металлическая связь характерна для…',opts:['Солей','Газов','Металлов и сплавов','Кислот'],a:2,ex:'Металлы и сплавы.'}
],
p41:[
{q:'Вещества с ионной решёткой (соли) обычно…',opts:['Летучие','Тугоплавкие, твёрдые','Газы','Жидкие'],a:1,ex:'Ионная решётка прочная → высокие t плавления.'},
{q:'Какую решётку имеет алмаз?',opts:['Ионную','Атомную','Молекулярную','Металлическую'],a:1,ex:'Атомная решётка → очень твёрдый.'},
{q:'Вещества с молекулярной решёткой (лёд, CO₂) обычно…',opts:['Тугоплавкие','Летучие, легкоплавкие','Проводят ток','Очень твёрдые'],a:1,ex:'Слабые межмолекулярные силы → летучи.'}
],
final1:[
{q:'Связь в Cl₂ — это…',opts:['Полярная','Неполярная ковалентная','Ионная','Металлическая'],a:1,ex:'Одинаковые атомы → неполярная.'},
{q:'Связь в H₂O — это…',opts:['Неполярная','Полярная ковалентная','Ионная','Металлическая'],a:1,ex:'O и H разной ЭО → полярная.'},
{q:'Связь в KCl — это…',opts:['Ковалентная','Ионная','Металлическая','Водородная'],a:1,ex:'Металл + неметалл → ионная.'},
{q:'Сколько электронов в завершённом внешнем слое (октет)?',hint:'',unit:'',a:8,ex:'8.'},
{q:'Какой тип решётки у поваренной соли NaCl?',opts:['Молекулярная','Ионная','Атомная','—'],a:1,ex:'Ионная решётка.'},
{q:'Что переносит электрический ток в металле?',opts:['Ионы','Свободные электроны','Молекулы','Протоны'],a:1,ex:'Свободные электроны.'},
{q:'Какая связь в молекуле N₂?',opts:['Ионная','Ковалентная неполярная','Металлическая','Полярная'],a:1,ex:'Одинаковые атомы → неполярная ковалентная.'},
{q:'Сколько общих электронных пар в молекуле N₂ (тройная связь)?',unit:'',a:3,ex:'3 пары.'} /*U5-extra*/
]
};
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
window.BUILDERS = { p36:bp36, p37:bp37, p38:bp38, p39:bp39, p40:bp40, p41:bp41, final1:bfinal };
function bp36(){ document.getElementById('p36-body').innerHTML =
hero(5,'§ 36 · Глава 4','Природа химической связи','правило октета','Что заставляет атомы соединяться: стремление к устойчивости завершённого слоя.',['октет','энергия'])
+makeCard('theory','Почему возникает связь','§36','<p>Атомы инертных газов почти не реагируют — у них <b>завершённый внешний слой</b> (8 электронов, у He — 2). Остальные атомы стремятся достичь такого же устойчивого состояния, отдавая, принимая или обобществляя электроны. Так возникает <b>химическая связь</b>.</p><div class="def-box">При образовании связи система переходит в более устойчивое состояние с меньшей энергией — поэтому энергия <b>выделяется</b>.</div>')
+rememberBox(['Цель связи — завершить внешний слой (октет).','Связанное состояние устойчивее, энергия выделяется.'])
+qList(['Почему инертные газы малоактивны?','Сколько электронов в завершённом слое?'])
+secNav(null,'p37')+readButton('p36'); wireReadBtn('p36'); }
function bp37(){ document.getElementById('p37-body').innerHTML =
hero(6,'§ 37 · Глава 4','Ковалентная связь','общие пары','Атомы неметаллов делятся электронами, образуя общие пары — это ковалентная связь.',['общие пары','неметаллы'])
+makeCard('theory','Общие электронные пары','§37','<p><b>Ковалентная связь</b> образуется за счёт <b>общих электронных пар</b> между атомами (обычно неметаллов). Каждый атом «достраивает» свой внешний слой за счёт общих электронов.</p><p>Связь бывает <b>одинарная</b> (HH, одна пара), <b>двойная</b> (O=O, две пары), <b>тройная</b> (N≡N, три пары).</p>')
+flag('Тип связи по электроотрицательности','Выбери два атома — увидишь тип связи и смещение общей пары. Для одинаковых атомов связь неполярная ковалентная.','<div id="c-bond1"></div>')
+rememberBox(['Ковалентная = общие электронные пары.','Чем больше общих пар, тем прочнее связь.'])
+qList(['Сколько общих пар в молекуле O₂?','Между какими атомами образуется ковалентная связь?'])
+secNav('p36','p38')+readButton('p37'); wireReadBtn('p37'); }
function bp38(){ document.getElementById('p38-body').innerHTML =
hero(4,'§ 38 · Глава 4','Полярная и неполярная связь. ЭО · Лаб. 4','ΔЭО','Если атомы тянут электроны одинаково — связь неполярна; если по-разному — полярна.',['ЭО','полярность','Лаб.4'])
+makeCard('theory','Электроотрицательность и полярность','§38','<div class="def-box"><b>Электроотрицательность (ЭО)</b> — способность атома притягивать к себе общие электроны.</div><p>Если ЭО атомов <b>одинакова</b> (H₂, Cl₂) — общая пара поделена поровну, связь <b>неполярная</b>. Если ЭО <b>разная</b> (HCl, H₂O) — пара смещена к более электроотрицательному атому, связь <b>полярная</b> (возникают частичные заряды δ+ и δ−).</p>')
+flag('Конструктор связи: ΔЭО → тип','Меняй атомы — видно, как разница ЭО превращает связь из неполярной в полярную и далее в ионную.','<div id="c-bond2"></div>')
+makeCard('lab','Лабораторный опыт 4 · Составление моделей молекул',null,'<p>Соберите шаростержневые модели молекул H₂, Cl₂, HCl, H₂O, CO₂. Определите для каждой тип связи (полярная/неполярная) и форму молекулы. Сравните: в симметричных молекулах (CO₂) полярные связи «компенсируются», и молекула в целом неполярна.</p>')
+flag('3D-модели молекул','Выбери молекулу и вращай её мышью. Снизу — молярная масса, тип связи, форма и полярность.','<div id="c-mol"></div>')
+rememberBox(['ΔЭО ≈ 0 → неполярная; ΔЭО заметна → полярная; ΔЭО велика → ионная.','Более электроотрицательный атом получает δ−.'])
+qList(['Полярна ли связь в Cl₂? А в HCl?','Что показывает электроотрицательность?'])
+secNav('p37','p39')+readButton('p38'); wireReadBtn('p38'); }
function bp39(){ document.getElementById('p39-body').innerHTML =
hero(9,'§ 39 · Глава 4','Ионная связь','Na⁺ Cl⁻','Когда разница ЭО очень велика, электрон переходит полностью — образуются ионы.',['ионы','передача e⁻'])
+makeCard('theory','Передача электронов','§39','<p>Если разница электроотрицательностей <b>очень велика</b> (металл + активный неметалл), электрон <b>полностью переходит</b> от металла к неметаллу. Образуются заряженные частицы — <b>ионы</b>: катион (+) и анион (−), которые притягиваются друг к другу. Это <b>ионная связь</b>.</p>')
+'<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> Образование ионной связи Na + Cl</div>'
+'<div class="bt-stage"><svg viewBox="0 0 260 80" class="bt-svg"><circle cx="55" cy="40" r="26" fill="var(--pri)" opacity=".15" stroke="var(--pri)" stroke-width="2"/><text x="55" y="46" text-anchor="middle" font-size="15" font-weight="800" fill="currentColor">Na</text><text x="55" y="14" text-anchor="middle" font-size="11" font-weight="800" fill="var(--pri)">1e⁻</text><path d="M85 40 h40" stroke="var(--pri)" stroke-width="2" marker-end="url(#ar)"/><defs><marker id="ar" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0 L6 3 L0 6 z" fill="var(--pri)"/></marker></defs><circle cx="205" cy="40" r="26" fill="var(--fail)" opacity=".15" stroke="var(--fail)" stroke-width="2"/><text x="205" y="46" text-anchor="middle" font-size="15" font-weight="800" fill="currentColor">Cl</text><text x="205" y="14" text-anchor="middle" font-size="11" font-weight="800" fill="var(--fail)">+1e⁻</text></svg></div>'
+'<div class="out ok"><span class="bd">Na e⁻ → Na⁺ &nbsp;|&nbsp; Cl + e⁻ → Cl⁻ &nbsp;→&nbsp; притяжение Na⁺Cl⁻ (ионная связь)</span></div></div>'
+rememberBox(['Ионная связь = полная передача электронов.','Металл → катион (+), неметалл → анион (−).'])
+qList(['Во что превращается хлор в NaCl?','Почему между Na и Cl именно ионная связь?'])
+secNav('p38','p40')+readButton('p39'); wireReadBtn('p39'); }
function bp40(){ document.getElementById('p40-body').innerHTML =
hero(7,'§ 40 · Глава 4','Металлическая связь. Межмолекулярное взаимодействие','электронный газ','В металлах общие электроны принадлежат сразу всем атомам — это объясняет их свойства.',['e-газ','ковкость','ток'])
+makeCard('theory','Металлическая связь','§40','<p>В металле внешние электроны легко отрываются и свободно перемещаются между положительными <b>ион-остовами</b>, образуя <b>«электронный газ»</b>. Эти общие электроны связывают все атомы — это <b>металлическая связь</b>.</p><p>Она объясняет свойства металлов: <b>электро- и теплопроводность</b> (электроны переносят заряд и энергию), <b>ковкость</b>, <b>металлический блеск</b>.</p><p><b>Межмолекулярное взаимодействие</b> — слабое притяжение между молекулами (включая водородную связь в воде) — удерживает молекулы в жидкостях и твёрдых телах.</p>')
+'<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg> Модель «электронного газа»</div><div class="bt-stage"><svg viewBox="0 0 220 90" class="bt-svg">'
+[ '40,30','90,30','140,30','40,65','90,65','140,65','180,48' ].map(function(p){var xy=p.split(',');return '<circle cx="'+xy[0]+'" cy="'+xy[1]+'" r="13" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="1.5"/><text x="'+xy[0]+'" y="'+(+xy[1]+4)+'" text-anchor="middle" font-size="9" font-weight="800" fill="currentColor">+</text>';}).join('')
+[ '65,48','115,48','160,32','65,20','115,78','30,48' ].map(function(p){var xy=p.split(',');return '<circle cx="'+xy[0]+'" cy="'+xy[1]+'" r="3" fill="var(--ok)"/>';}).join('')
+'</svg></div><div class="out"><span class="bd">«+» — ион-остовы металла, зелёные точки — свободные электроны (электронный газ).</span></div></div>'
+rememberBox(['Металлическая связь = ион-остовы + электронный газ.','Свободные электроны → проводимость, блеск, ковкость.'])
+qList(['Почему металлы проводят электрический ток?','Что такое «электронный газ»?'])
+secNav('p39','p41')+readButton('p40'); wireReadBtn('p40'); }
function bp41(){ document.getElementById('p41-body').innerHTML =
hero(8,'§ 41 · Глава 4','Кристаллическое состояние вещества','4 типа решёток','Тип связи определяет тип кристаллической решётки — а она определяет свойства вещества.',['решётки','свойства'])
+makeCard('theory','Типы кристаллических решёток','§41','<p>В кристалле частицы расположены упорядоченно, образуя <b>решётку</b>. От её типа зависят свойства вещества:</p>'
+'<div class="lat-grid">'
+'<div class="lat-card"><h4>Ионная</h4><div class="lat-ex">NaCl, CaCO₃</div><ul><li>узлы — ионы</li><li>твёрдые, тугоплавкие</li><li>хрупкие</li></ul></div>'
+'<div class="lat-card"><h4>Атомная</h4><div class="lat-ex">алмаз, SiO₂</div><ul><li>узлы — атомы (ков. связь)</li><li>очень твёрдые</li><li>тугоплавкие</li></ul></div>'
+'<div class="lat-card"><h4>Молекулярная</h4><div class="lat-ex">лёд, CO₂, I₂</div><ul><li>узлы — молекулы</li><li>летучие, легкоплавкие</li><li>мягкие</li></ul></div>'
+'<div class="lat-card"><h4>Металлическая</h4><div class="lat-ex">Fe, Cu, Al</div><ul><li>ион-остовы + e-газ</li><li>ковкие, проводят ток</li><li>блеск</li></ul></div>'
+'</div>')
+flag('3D-модель кристаллической решётки','Выбери тип решётки и вращай её мышью.','<div id="c-lattice"></div>')
+rememberBox(['Ионная и атомная решётки — прочные, тугоплавкие.','Молекулярная — летучая, легкоплавкая.','Тип решётки ← тип связи → свойства.'])
+qList(['Какой тип решётки у льда? Почему он легкоплавкий?','Чем объясняется твёрдость алмаза?'])
+secNav('p40','final1')+readButton('p41'); wireReadBtn('p41'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 4','Босс: химическая связь','ковалентная · ионная · металлическая','Шесть интегрированных задач по всей главе. Победи босса — ачивка «Химическая связь освоена».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>Ковалентная</h3><div class="main-f">общие пары</div></div><div class="fcard"><h3>Ионная</h3><div class="main-f">передача e⁻</div></div><div class="fcard"><h3>Металлическая</h3><div class="main-f">электронный газ</div></div><div class="fcard highlight"><h3>Решётка</h3><div class="main-f">тип → свойства</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи — за каждую +5 XP, за победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p41',null); }
</script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 5 · «Окислительно-восстановительные реакции»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 5 — orange */
:root{ --pri:#ea580c; --pri-d:#c2410c; --pri-l:#fb923c; --pri-soft:#ffedd5; --sec-acc:#ea580c; --sec-acc-d:#c2410c; --sec-acc-soft:#ffedd5; }
html.dark{ --bg:#1c1208; --card:#2a1c10; --card-soft:#33240f; --text:#ffedd5; --muted:#fdba74; --border:#4a3115; --pri-soft:rgba(234,88,12,.22); --sec-acc-soft:rgba(234,88,12,.22); }
.hdr{background:linear-gradient(110deg,#9a3412 0%,#ea580c 55%,#fb923c 100%)}
.hdr::before{content:'ГЛАВА 5'}
</style>
<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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch5_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 5</h1>
<div class="hdr-sub">Степень окисления, процессы окисления и восстановления, ОВР и метод электронного баланса</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Реакции, в которых электроны меняют хозяина</h2>
<p>Горение, ржавление, дыхание, работа батарейки — всё это окислительно-восстановительные реакции. В них одни атомы отдают электроны, другие принимают, и степени окисления меняются.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p42')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 42</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p42" class="sec"><div class="sec-header"><span class="sec-num">§ 42</span><h2 class="sec-h">Степень окисления</h2></div><div id="p42-body"></div></section>
<section id="sec-p43" class="sec"><div class="sec-header"><span class="sec-num">§ 43</span><h2 class="sec-h">Процессы окисления и восстановления</h2></div><div id="p43-body"></div></section>
<section id="sec-p44" class="sec"><div class="sec-header"><span class="sec-num">§ 44</span><h2 class="sec-h">Окислительно-восстановительные реакции</h2></div><div id="p44-body"></div></section>
<section id="sec-p45" class="sec"><div class="sec-header"><span class="sec-num">§ 45</span><h2 class="sec-h">Окислительно-восстановительные реакции вокруг нас</h2></div><div id="p45-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 5 · «Окислительно-восстановительные реакции» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch5', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch5_progress', achKey:'chemistry8_ch5_ach' };
window.PARAS = [
{id:'p42',num:'§ 42',name:'Степень окисления',sub:'+/ на атоме'},
{id:'p43',num:'§ 43',name:'Окисление и восстановление',sub:'отдал/принял e⁻'},
{id:'p44',num:'§ 44',name:'ОВР',sub:'электронный баланс'},
{id:'p45',num:'§ 45',name:'ОВР вокруг нас',sub:'горение, коррозия'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 5!', final1_tasks:'ОВР освоены!' };
['p42','p43','p44','p45'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p42:{title:'§42 С.о.',rows:[['С.о.','условный заряд атома'],['H','+1 (обычно)'],['O','2 (обычно)'],['Σ','с.о. = 0 (молекула)']]},
p43:{title:'§43 Окисл./восст.',rows:[['Окисление','отдача e⁻ (с.о. ↑)'],['Восстановление','приём e⁻ (с.о. ↓)'],['Восстановитель','отдаёт e⁻'],['Окислитель','принимает e⁻']]},
p44:{title:'§44 ОВР',rows:[['ОВР','меняются с.о.'],['Метод','электронный баланс'],['Баланс','отдано e⁻ = принято e⁻']]},
p45:{title:'§45 Вокруг нас',rows:[['Горение','+ O₂'],['Коррозия','ржавление Fe'],['Батарейка','ток из ОВР'],['Дыхание','окисление глюкозы']]},
final1:{title:'Финал главы 5',rows:[['§§4245','ОВР'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p42',html:'Степень окисления — условный заряд атома. H обычно +1, O обычно −2, сумма с.о. в молекуле = 0.'},
{sec:'p43',html:'Окисление — отдача электронов (с.о. растёт). Восстановление — приём электронов (с.о. падает).'},
{sec:'p44',html:'В ОВР число отданных электронов = числу принятых (метод электронного баланса).'},
{sec:'p45',html:'Горение, коррозия, дыхание, работа батарейки — всё это ОВР.'},
{sec:'final1',html:'С.о., окислитель/восстановитель, электронный баланс — повтори перед боссом.'}
];
window.POOLS = {
p42:[
{q:'Степень окисления — это…',opts:['Масса атома','Условный заряд атома в соединении','Число нейтронов','Валентность ядра'],a:1,ex:'Условный заряд, который был бы у атома, если все связи ионные.'},
{q:'Степень окисления кислорода в большинстве соединений:',opts:['+2','2','0','+6'],a:1,ex:'O обычно 2.'},
{q:'Степень окисления серы в H₂SO₄:',hint:'H +1, O 2, Σ=0',unit:'',a:6,ex:'2·(+1)+S+4·(2)=0 → S=+6.'},
{q:'Степень окисления любого простого вещества (O₂, Fe) равна…',hint:'',unit:'',a:0,ex:'У простых веществ с.о. = 0.'}
],
p43:[
{q:'Окисление — это процесс…',opts:['Приёма электронов','Отдачи электронов','Распада ядра','Растворения'],a:1,ex:'Окисление — отдача e⁻ (с.о. растёт).'},
{q:'Восстановитель в реакции…',opts:['Принимает e⁻','Отдаёт e⁻','Не меняется','Распадается'],a:1,ex:'Восстановитель отдаёт электроны (сам окисляется).'},
{q:'Окислитель — это частица, которая…',opts:['Отдаёт e⁻','Принимает e⁻','Растворяется','Испаряется'],a:1,ex:'Окислитель принимает электроны (сам восстанавливается).'}
],
p44:[
{q:'В основе ОВР лежит…',opts:['Обмен ионами','Переход электронов','Обмен молекулами','Изменение цвета'],a:1,ex:'Переход электронов между частицами.'},
{q:'Метод электронного баланса требует, чтобы…',opts:['Масса росла','Число отданных e⁻ = числу принятых','Объём не менялся','Цвет совпадал'],a:1,ex:'Отдано e⁻ = принято e⁻.'},
{q:'В реакции 2Mg + O₂ → 2MgO магний…',opts:['Окислитель','Восстановитель','Не участвует','Катализатор'],a:1,ex:'Mg отдаёт электроны → восстановитель.'}
],
p45:[
{q:'Что из перечисленного НЕ является ОВР?',opts:['Горение угля','Ржавление железа','Растворение соли в воде','Работа батарейки'],a:2,ex:'Растворение соли — без изменения с.о.'},
{q:'При ржавлении железа Fe…',opts:['Принимает e⁻','Отдаёт e⁻','Не меняется','Испаряется'],a:1,ex:'Fe окисляется (отдаёт e⁻), с.о. растёт.'},
{q:'Источник тока в батарейке — это…',opts:['Реакция обмена','ОВР','Растворение','Плавление'],a:1,ex:'Ток возникает за счёт ОВР.'}
],
final1:[
{q:'С.о. азота в HNO₃:',hint:'H +1, O 2',unit:'',a:5,ex:'+1+N+3·(2)=0 → N=+5.'},
{q:'Окисление — это…',opts:['Приём e⁻','Отдача e⁻','Распад','Растворение'],a:1,ex:'Отдача электронов.'},
{q:'В 2Na + Cl₂ → 2NaCl хлор является…',opts:['Восстановителем','Окислителем','Катализатором','Растворителем'],a:1,ex:'Cl₂ принимает e⁻ → окислитель.'},
{q:'С.о. простого вещества равна…',hint:'',unit:'',a:0,ex:'0.'},
{q:'С.о. марганца в KMnO₄:',hint:'K +1, O 2',unit:'',a:7,ex:'+1+Mn+4·(2)=0 → Mn=+7.'},
{q:'Сколько электронов отдаёт Mg при окислении до Mg⁺²?',hint:'',unit:'',a:2,ex:'2 электрона.'},
{q:'Степень окисления хлора в HCl?',hint:'H +1, Σ=0',unit:'',a:-1,tol:0.1,step:'1',ex:'Cl: 1.'},
{q:'Окислитель в реакции электроны…',opts:['отдаёт','принимает','не меняет','растворяет'],a:1,ex:'Окислитель принимает электроны.'} /*U5-extra*/
]
};
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function wgt(title, inner){ return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
window.BUILDERS = { p42:bp42, p43:bp43, p44:bp44, p45:bp45, final1:bfinal };
function bp42(){ document.getElementById('p42-body').innerHTML =
hero(6,'§ 42 · Глава 5','Степень окисления','с.о.','Условный заряд атома, который помогает «считать» электроны в соединениях.',['с.о.','H +1','O 2'])
+makeCard('theory','Что такое степень окисления','§42','<div class="def-box"><b>Степень окисления</b> — условный заряд атома в соединении, вычисленный из предположения, что все связи ионные.</div><p>Правила: у простых веществ с.о. = 0; водород обычно <b>+1</b>, кислород обычно <b>2</b>; сумма степеней окисления в молекуле равна <b>0</b>. По этим правилам находят с.о. остальных элементов.</p>')
+flag('Калькулятор степени окисления','Введи формулу — определятся степени окисления всех элементов.','<div id="c-ox"></div>')
+rememberBox(['У простого вещества с.о. = 0.','H → +1, O → 2 (как правило).','Сумма с.о. в нейтральной молекуле = 0.'])
+qList(['Найди с.о. серы в SO₃.','Чему равна с.о. железа в Fe₂O₃?'])
+secNav(null,'p43')+readButton('p42'); wireReadBtn('p42'); }
function bp43(){ document.getElementById('p43-body').innerHTML =
hero(5,'§ 43 · Глава 5','Процессы окисления и восстановления','отдал / принял e⁻','Два процесса, которые всегда идут вместе: один атом отдаёт электроны, другой их принимает.',['окисление','восстановление'])
+makeCard('theory','Окисление и восстановление','§43','<p><b>Окисление</b> — отдача электронов, степень окисления <b>повышается</b>.<br><b>Восстановление</b> — приём электронов, степень окисления <b>понижается</b>.</p><div class="def-box"><b>Восстановитель</b> отдаёт электроны (сам окисляется). <b>Окислитель</b> принимает электроны (сам восстанавливается).</div>')
+'<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12h18"/></svg> Кто есть кто</div><div class="lat-grid"><div class="lat-card"><h4>Восстановитель</h4><ul><li>отдаёт e⁻</li><li>с.о. растёт</li><li>сам окисляется</li><li>пример: Mg⁰ → Mg⁺²</li></ul></div><div class="lat-card"><h4>Окислитель</h4><ul><li>принимает e⁻</li><li>с.о. падает</li><li>сам восстанавливается</li><li>пример: O₂⁰ → O⁻²</li></ul></div></div></div>'
+rememberBox(['Окисление — отдача e⁻ (с.о. ↑); восстановление — приём e⁻ (с.о. ↓).','Восстановитель окисляется, окислитель восстанавливается.'])
+qList(['Что происходит со степенью окисления при окислении?','Кто отдаёт электроны — окислитель или восстановитель?'])
+secNav('p42','p44')+readButton('p43'); wireReadBtn('p43'); }
function bp44(){ document.getElementById('p44-body').innerHTML =
hero(8,'§ 44 · Глава 5','Окислительно-восстановительные реакции','электронный баланс','Реакции с переходом электронов уравнивают особым методом — электронным балансом.',['ОВР','баланс e⁻'])
+makeCard('theory','Метод электронного баланса','§44','<p><b>ОВР</b> — реакции, в которых меняются степени окисления (происходит переход электронов). Их уравнивают <b>методом электронного баланса</b>:</p><ol><li>Определить с.о. и найти, кто отдаёт, а кто принимает электроны.</li><li>Записать полуреакции: сколько e⁻ отдано и принято.</li><li>Подобрать множители так, чтобы <b>отдано e⁻ = принято e⁻</b>.</li><li>Перенести множители в уравнение как коэффициенты.</li></ol>')
+flag('Пошаговый электронный баланс','Выбери реакцию и разбери метод по шагам.','<div class="fld"><label>Реакция</label><select id="c-redox-pick"></select></div><div class="out" id="c-redox-out"></div><div class="fld"><button class="btn" id="c-redox-step">Следующий шаг ▸</button><button class="btn" id="c-redox-all">Показать всё</button></div>')
+rememberBox(['ОВР = изменение степеней окисления.','Главное правило: отдано e⁻ = принято e⁻.'])
+qList(['Кто восстановитель в 2Mg + O₂ → 2MgO?','Сколько электронов принимает O₂ в этой реакции?'])
+secNav('p43','p45')+readButton('p44'); wireReadBtn('p44'); }
function bp45(){ document.getElementById('p45-body').innerHTML =
hero(9,'§ 45 · Глава 5','ОВР вокруг нас','горение · коррозия · ток','ОВР — не абстракция: они греют, двигают, питают и, увы, разрушают.',['горение','коррозия','батарейка'])
+makeCard('theory','ОВР в жизни','§45','<p>Окислительно-восстановительные реакции окружают нас повсюду:</p>'
+'<div class="lat-grid">'
+'<div class="lat-card"><h4>Горение</h4><ul><li>топливо + O₂</li><li>выделяется тепло</li><li>C + O₂ → CO₂</li></ul></div>'
+'<div class="lat-card"><h4>Коррозия</h4><ul><li>ржавление железа</li><li>Fe → Fe₂O₃·nH₂O</li><li>разрушает металл</li></ul></div>'
+'<div class="lat-card"><h4>Дыхание</h4><ul><li>окисление глюкозы</li><li>даёт энергию клеткам</li></ul></div>'
+'<div class="lat-card"><h4>Батарейка</h4><ul><li>ток из ОВР</li><li>энергия реакции → электричество</li></ul></div>'
+'</div>')
+rememberBox(['Горение, дыхание, коррозия, батарейки — это ОВР.','Растворение соли — НЕ ОВР (с.о. не меняется).'])
+qList(['Приведи три примера ОВР из жизни.','Почему растворение сахара — не ОВР?'])
+secNav('p44','final1')+readButton('p45'); wireReadBtn('p45'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 5','Босс: ОВР','с.о. · окислитель/восстановитель · баланс','Шесть интегрированных задач по всей главе. Победи босса — ачивка «ОВР освоены».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>С.о.</h3><div class="main-f">H +1, O 2, Σ=0</div></div><div class="fcard"><h3>Окисление</h3><div class="main-f">e⁻, с.о. ↑</div></div><div class="fcard"><h3>Восстановление</h3><div class="main-f">+e⁻, с.о. ↓</div></div><div class="fcard highlight"><h3>Баланс</h3><div class="main-f">отдано = принято</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи — за каждую +5 XP, за победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p45',null); }
</script>
</body>
</html>
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · Глава 6 · «Растворы»</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&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">
<link rel="stylesheet" href="/css/chem8-textbook.css">
<style>
/* Глава 6 — cyan */
:root{ --pri:#0891b2; --pri-d:#0e7490; --pri-l:#22d3ee; --pri-soft:#cffafe; --sec-acc:#0891b2; --sec-acc-d:#0e7490; --sec-acc-soft:#cffafe; }
html.dark{ --bg:#08191c; --card:#10282d; --card-soft:#143539; --text:#cffafe; --muted:#67e8f9; --border:#1a4046; --pri-soft:rgba(8,145,178,.22); --sec-acc-soft:rgba(8,145,178,.22); }
.hdr{background:linear-gradient(110deg,#164e63 0%,#0891b2 55%,#22d3ee 100%)}
.hdr::before{content:'ГЛАВА 6'}
</style>
<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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_ch6_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Глава 6</h1>
<div class="hdr-sub">Смеси и растворы, растворимость, массовая доля и молярная концентрация, вода в жизни человека</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Самые важные смеси на Земле</h2>
<p>Морская вода, кровь, лимонад, воздух — это всё растворы и смеси. Химик умеет описывать их состав количественно: массовой долей и молярной концентрацией.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p46')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 46</button>
<div class="hero-progress"><span class="hp-label">Прогресс главы</span><div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div><span id="hero-hp-text" class="hp-text">0%</span></div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel"><div class="psel-title">Параграфы главы</div><div id="psel-grid" class="psel-grid"></div></section>
<section id="sec-p46" class="sec"><div class="sec-header"><span class="sec-num">§ 46</span><h2 class="sec-h">Смеси веществ</h2></div><div id="p46-body"></div></section>
<section id="sec-p47" class="sec"><div class="sec-header"><span class="sec-num">§ 47</span><h2 class="sec-h">Растворение веществ в воде</h2></div><div id="p47-body"></div></section>
<section id="sec-p48" class="sec"><div class="sec-header"><span class="sec-num">§ 48</span><h2 class="sec-h">Характеристики растворимости веществ</h2></div><div id="p48-body"></div></section>
<section id="sec-p49" class="sec"><div class="sec-header"><span class="sec-num">§ 49</span><h2 class="sec-h">Качественные характеристики состава растворов</h2></div><div id="p49-body"></div></section>
<section id="sec-p50" class="sec"><div class="sec-header"><span class="sec-num">§ 50</span><h2 class="sec-h">Массовая доля растворённого вещества</h2></div><div id="p50-body"></div></section>
<section id="sec-p51" class="sec"><div class="sec-header"><span class="sec-num">§ 51</span><h2 class="sec-h">Молярная концентрация · ПР 4</h2></div><div id="p51-body"></div></section>
<section id="sec-p52" class="sec"><div class="sec-header"><span class="sec-num">§ 52</span><h2 class="sec-h">Вода и растворы в жизни человека</h2></div><div id="p52-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал главы</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Глава 6 · «Растворы» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
window.CHEM8_CFG = { slug:'chemistry-8-ch6', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp', progKey:'chemistry8_ch6_progress', achKey:'chemistry8_ch6_ach' };
window.PARAS = [
{id:'p46',num:'§ 46',name:'Смеси веществ',sub:'однород./неоднород.'},
{id:'p47',num:'§ 47',name:'Растворение в воде',sub:'гидратация'},
{id:'p48',num:'§ 48',name:'Растворимость',sub:'s = f(t)'},
{id:'p49',num:'§ 49',name:'Качественные характеристики',sub:'насыщ./ненасыщ.'},
{id:'p50',num:'§ 50',name:'Массовая доля',sub:'w = m/m'},
{id:'p51',num:'§ 51',name:'Молярная концентрация · ПР4',sub:'c = n/V'},
{id:'p52',num:'§ 52',name:'Вода в жизни',sub:'значение'},
{id:'final1',num:'★',name:'Финал главы',sub:'босс · ачивка',final:true}
];
window.ACH_LABELS = { start:'Начало главы 6!', final1_tasks:'Растворы освоены!' };
['p46','p47','p48','p49','p50','p51','p52'].forEach(function(id){ window.ACH_LABELS[id+'_done']=id.toUpperCase()+' изучен!'; });
window.SIDEBARS = {
p46:{title:'§46 Смеси',rows:[['Однородные','раствор, воздух, сплав'],['Неоднородные','песок+вода, гранит'],['Разделение','фильтрование, выпаривание']]},
p47:{title:'§47 Растворение',rows:[['Раствор','растворитель + в-во'],['Гидратация','вода окружает частицы'],['Тепловой эффект','+ или тепло']]},
p48:{title:'§48 Растворимость',rows:[['Растворимость','г на 100 г воды'],['Зависит','от t (для большинства)'],['Кривая','s = f(t)']]},
p49:{title:'§49 Характеристики',rows:[['Ненасыщенный','можно ещё растворить'],['Насыщенный','предел растворения'],['Разбавл./конц.','мало/много в-ва']]},
p50:{title:'§50 Массовая доля',rows:[['w','= m(в-ва)/m(р-ра)'],['В %','×100%'],['m(р-ра)','= m(в-ва)+m(воды)']]},
p51:{title:'§51 Концентрация',rows:[['c','= n/V'],['Единица','моль/л'],['ПР4','приготовление раствора']]},
p52:{title:'§52 Вода',rows:[['Растворитель','№1 в природе'],['Очистка','фильтрование, кипячение'],['В быту','напитки, лекарства']]},
final1:{title:'Финал главы 6',rows:[['§§4652','растворы'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p46',html:'Однородные смеси (растворы) — состав одинаков во всём объёме (воздух, раствор соли, сплав). Неоднородные — видны части (песок + вода).'},
{sec:'p47',html:'При растворении частицы вещества окружаются молекулами воды (гидратация). Может выделяться или поглощаться тепло.'},
{sec:'p48',html:'Растворимость — масса вещества, растворяющаяся в 100 г воды. У большинства твёрдых веществ растёт с температурой.'},
{sec:'p49',html:'Ненасыщенный раствор может растворить ещё; насыщенный — достиг предела при данной температуре.'},
{sec:'p50',html:'Массовая доля w = m(вещества) / m(раствора); m(раствора) = m(вещества) + m(воды).'},
{sec:'p51',html:'Молярная концентрация c = n/V (моль/л). Сначала найди n = m/M.'},
{sec:'p52',html:'Вода — универсальный растворитель и основа жизни; её очищают фильтрованием, отстаиванием, кипячением.'},
{sec:'final1',html:'Смеси, растворимость, w и c — повтори перед боссом.'}
];
window.POOLS = {
p46:[
{q:'Однородная смесь (раствор) — это смесь, в которой…',opts:['Видны отдельные части','Состав одинаков во всём объёме','Всегда есть осадок','Только газы'],a:1,ex:'В растворе компоненты не различимы.'},
{q:'Какая смесь неоднородная?',opts:['Воздух','Раствор сахара','Песок и вода','Сталь'],a:2,ex:'Песок + вода — видны части.'},
{q:'Как разделить смесь песка и воды?',opts:['Выпариванием','Фильтрованием','Перегонкой','Намагничиванием'],a:1,ex:'Фильтрование отделяет нерастворимый песок.'}
],
p47:[
{q:'Раствор состоит из…',opts:['Только растворителя','Растворителя и растворённого вещества','Только осадка','Двух газов'],a:1,ex:'Растворитель + растворённое вещество.'},
{q:'Процесс окружения частиц вещества молекулами воды называют…',opts:['Гидратацией','Фильтрованием','Кипением','Кристаллизацией'],a:0,ex:'Гидратация.'},
{q:'При растворении некоторых веществ раствор нагревается, потому что…',opts:['Вода кипит','Выделяется тепло','Свет','Газ'],a:1,ex:'Тепловой эффект растворения.'}
],
p48:[
{q:'Растворимость измеряют в…',opts:['Граммах на 100 г воды','Литрах','Молях на грамм','Процентах массы ядра'],a:0,ex:'г вещества на 100 г воды.'},
{q:'Как меняется растворимость большинства твёрдых веществ при нагревании?',opts:['Падает','Растёт','Не меняется','Исчезает'],a:1,ex:'Обычно растёт с температурой.'},
{q:'У какого вещества растворимость почти не зависит от температуры?',opts:['KNO₃','NaCl','сахар','—'],a:1,ex:'У NaCl растворимость почти постоянна.'}
],
p49:[
{q:'Насыщенный раствор — это раствор, в котором…',opts:['Мало вещества','Вещество больше не растворяется при данной t','Есть осадок всегда','Только вода'],a:1,ex:'Достигнут предел растворения.'},
{q:'Ненасыщенный раствор…',opts:['Нельзя разбавить','Может растворить ещё вещества','Всегда с осадком','Кипит'],a:1,ex:'Ещё не достиг предела.'},
{q:'«Концентрированный» раствор означает, что вещества в нём…',opts:['Мало','Много','Нет','Только газ'],a:1,ex:'Много растворённого вещества.'}
],
p50:[
{q:'Массовая доля w равна…',opts:['m(в-ва)/m(воды)','m(в-ва)/m(раствора)','m(раствора)/m(в-ва)','n/V'],a:1,ex:'w = m(вещества)/m(раствора).'},
{q:'В 80 г воды растворили 20 г соли. Чему равна w (%)?',hint:'20/(20+80)·100',unit:'%',a:20,ex:'20/100·100 = 20 %.'},
{q:'Масса раствора при растворении 5 г соли в 95 г воды равна…',hint:'5+95',unit:'г',a:100,ex:'m(р-ра)=100 г.'}
],
p51:[
{q:'Молярная концентрация c равна…',opts:['m/V','n/V','n·V','m·M'],a:1,ex:'c = n/V (моль/л).'},
{q:'40 г NaOH (M=40) растворили до объёма 1 л. Чему равна c?',hint:'n=m/M=1; c=n/V',unit:'моль/л',a:1,ex:'n=1 моль, c=1/1=1 моль/л.'},
{q:'В 2 л раствора содержится 1 моль вещества. Концентрация равна…',hint:'c=n/V',unit:'моль/л',a:0.5,ex:'1/2 = 0,5 моль/л.'}
],
p52:[
{q:'Почему воду называют универсальным растворителем?',opts:['Она прозрачна','Растворяет очень многие вещества','Она лёгкая','Она кипит'],a:1,ex:'Вода растворяет множество веществ.'},
{q:'Какой способ НЕ используют для очистки воды?',opts:['Фильтрование','Отстаивание','Кипячение','Замораживание соли в неё'],a:3,ex:'Очищают фильтрованием, отстаиванием, кипячением.'},
{q:'Морская вода — это…',opts:['Чистое вещество','Раствор солей','Простое вещество','Оксид'],a:1,ex:'Раствор солей в воде.'}
],
final1:[
{q:'Какая смесь однородная?',opts:['Песок + вода','Молоко','Раствор соли','Гранит'],a:2,ex:'Раствор соли — однородная.'},
{q:'Растворимость большинства твёрдых веществ при нагревании…',opts:['Падает','Растёт','Не меняется','—'],a:1,ex:'Растёт.'},
{q:'В 150 г воды растворили 50 г соли. w (%) =',hint:'50/200·100',unit:'%',a:25,ex:'25 %.'},
{q:'20 г NaOH (M=40) в 0,5 л раствора. c (моль/л) =',hint:'n=0,5; c=0,5/0,5',unit:'моль/л',a:1,ex:'1 моль/л.'},
{q:'Раствор, который больше не растворяет вещество, называют…',opts:['Ненасыщенным','Насыщенным','Разбавленным','Чистым'],a:1,ex:'Насыщенный.'},
{q:'Молярную концентрацию измеряют в…',opts:['граммах','моль/л','литрах','процентах'],a:1,ex:'моль/л.'},
{q:'В 90 г воды растворили 10 г соли. Чему равна w (%)?',hint:'10/100·100',unit:'%',a:10,ex:'10 %.'},
{q:'0,5 моль вещества в 0,5 л раствора. Чему равна c (моль/л)?',hint:'c=n/V',unit:'моль/л',a:1,ex:'0,5/0,5=1 моль/л.'} /*U5-extra*/
]
};
function rememberBox(items){ return '<div class="remember-box"><div class="remember-box-title"><svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>'; }
function qList(items){ return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>'; }
function flag(title, help, inner){ return '<div class="flag-card"><div class="flag-title">'+title+'</div><div class="flag-help">'+help+'</div>'+inner+'</div>'; }
function wgt(title, inner){ return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>'; }
function hero(ph,label,title,formula,desc,tags){ return '<div class="para-hero ph-'+ph+'"><div class="ph-label">'+label+'</div><h2>'+title+'</h2>'+(formula?'<div class="ph-formula">'+formula+'</div>':'')+'<div class="ph-desc">'+desc+'</div>'+(tags?'<div class="ph-tags">'+tags.map(function(t){return '<span class="ph-tag">'+t+'</span>';}).join('')+'</div>':'')+'</div>'; }
window.BUILDERS = { p46:bp46, p47:bp47, p48:bp48, p49:bp49, p50:bp50, p51:bp51, p52:bp52, final1:bfinal };
function bp46(){ document.getElementById('p46-body').innerHTML =
hero(7,'§ 46 · Глава 6','Смеси веществ','однород. / неоднород.','Большинство веществ вокруг — смеси. Их делят по тому, видны ли компоненты.',['смесь','разделение'])
+makeCard('theory','Виды смесей','§46','<p><b>Смесь</b> — несколько веществ вместе. <b>Однородные</b> смеси (растворы) — состав одинаков во всём объёме, частицы не видны (воздух, раствор соли, сплав). <b>Неоднородные</b> — компоненты различимы (песок + вода, молоко, гранит).</p><p>Смеси разделяют физическими способами: <b>фильтрование</b>, <b>отстаивание</b>, <b>выпаривание</b>, <b>перегонка</b>, действие магнитом.</p>')
+flag('Классификатор смесей','Распредели смеси на однородные и неоднородные.','<div id="c-mix"></div>')
+rememberBox(['Раствор — это однородная смесь.','Смеси разделяют физическими методами, без реакций.'])
+qList(['Чем раствор отличается от неоднородной смеси?','Как разделить смесь соли и песка?'])
+secNav(null,'p47')+readButton('p46'); wireReadBtn('p46'); }
function bp47(){ document.getElementById('p47-body').innerHTML =
hero(4,'§ 47 · Глава 6','Растворение веществ в воде','растворитель + в-во','Что происходит, когда сахар «исчезает» в чае: вода разбирает вещество на частицы.',['раствор','гидратация'])
+makeCard('theory','Как идёт растворение','§47','<p><b>Раствор</b> = растворитель (чаще вода) + растворённое вещество. При растворении молекулы воды окружают частицы вещества и «растаскивают» их — это <b>гидратация</b>. При этом тепло может выделяться (растворение щёлочи) или поглощаться (растворение соли).</p>')
+flag('Анимация растворения и гидратации','Выбери вещество — увидишь, как оно распадается на ионы, окружённые молекулами воды.','<div id="c-dissoc"></div>')
+rememberBox(['Раствор = растворитель + растворённое вещество.','Гидратация — окружение частиц молекулами воды.'])
+qList(['Из чего состоит любой раствор?','Что такое гидратация?'])
+secNav('p46','p48')+readButton('p47'); wireReadBtn('p47'); }
function bp48(){ document.getElementById('p48-body').innerHTML =
hero(8,'§ 48 · Глава 6','Характеристики растворимости веществ','s = f(t)','Сколько вещества можно растворить и как на это влияет температура.',['растворимость','кривая'])
+makeCard('theory','Растворимость','§48','<div class="def-box"><b>Растворимость</b> — масса вещества (в граммах), которая растворяется в 100 г воды при данной температуре до образования насыщенного раствора.</div><p>У большинства твёрдых веществ растворимость <b>растёт</b> с температурой (KNO₃), у некоторых почти не меняется (NaCl). Зависимость показывает <b>кривая растворимости</b>.</p>')
+flag('Кривая растворимости s = f(t)','Двигай температуру — точка идёт по кривой; сравни KNO₃ и NaCl.','<div id="c-solcurve"></div>')
+rememberBox(['Растворимость — г на 100 г воды.','У большинства твёрдых веществ растёт с t.'])
+qList(['Как меняется растворимость KNO₃ при нагревании?','Почему растворимость NaCl почти постоянна?'])
+secNav('p47','p49')+readButton('p48'); wireReadBtn('p48'); }
function bp49(){ document.getElementById('p49-body').innerHTML =
hero(5,'§ 49 · Глава 6','Качественные характеристики состава растворов','насыщ. / ненасыщ.','Как «на словах» описать раствор: много или мало вещества, можно ли растворить ещё.',['насыщенный','концентрированный'])
+makeCard('theory','Качественные характеристики','§49','<p><b>Ненасыщенный</b> раствор — может растворить ещё вещества. <b>Насыщенный</b> — достиг предела растворения при данной температуре. <b>Пересыщенный</b> — содержит больше вещества, чем в насыщенном (неустойчив).</p><p><b>Разбавленный</b> — мало растворённого вещества, <b>концентрированный</b> — много. Это <b>качественные</b> (приблизительные) характеристики; точные — массовая доля и концентрация.</p>')
+rememberBox(['Насыщенный — предел растворения при данной t.','«Разбавленный/концентрированный» — приблизительно.'])
+qList(['Чем насыщенный раствор отличается от ненасыщенного?','Можно ли в насыщенный раствор добавить ещё вещества?'])
+secNav('p48','p50')+readButton('p49'); wireReadBtn('p49'); }
function bp50(){ document.getElementById('p50-body').innerHTML =
hero(6,'§ 50 · Глава 6','Массовая доля растворённого вещества','w = m(в-ва)/m(р-ра)','Главная количественная характеристика раствора в быту и на этикетках.',['w','%'])
+makeCard('theory','Массовая доля','§50','<div class="def-box"><b>Массовая доля</b> w = m(растворённого вещества) / m(раствора). Часто выражают в процентах (× 100 %). Масса раствора = масса вещества + масса растворителя.</div><p>Пример: в 80 г воды растворили 20 г соли. m(р-ра) = 100 г, w = 20/100 = 0,2 = 20 %.</p>')
+flag('Калькулятор массовой доли','Введи массы вещества и воды — получишь w.','<div id="c-wcalc"></div>')
+rememberBox(['w = m(в-ва)/m(р-ра); m(р-ра) = m(в-ва)+m(воды).','w часто выражают в %.'])
+qList(['Найди w, если 30 г сахара растворили в 120 г воды.','Сколько соли в 200 г раствора с w = 10 %?'])
+secNav('p49','p51')+readButton('p50'); wireReadBtn('p50'); }
function bp51(){ document.getElementById('p51-body').innerHTML =
hero(9,'§ 51 · Глава 6','Молярная концентрация · ПР 4','c = n/V','Точная «химическая» характеристика раствора — сколько моль вещества в литре.',['c','моль/л','ПР.4'])
+makeCard('theory','Молярная концентрация','§51','<div class="def-box"><b>Молярная концентрация</b> c = n/V — химическое количество растворённого вещества в 1 литре раствора, моль/л.</div><p>Чтобы найти c по массе: сначала n = m/M, затем c = n/V. Пример: 40 г NaOH (M = 40) в 1 л → n = 1 моль, c = 1 моль/л.</p>')
+flag('Калькулятор молярной концентрации','Введи формулу, массу и объём — получишь c = n/V.','<div id="c-ccalc"></div>')
+makeCard('lab','Практическая работа 4 · Приготовление раствора',null,'<p>Приготовь раствор с заданной массовой долей и молярной концентрацией: рассчитай нужную массу вещества, взвесь её, раствори в воде и доведи до нужного объёма в мерной колбе.</p>')
+rememberBox(['c = n/V (моль/л); сначала найди n = m/M.','Для приготовления раствора используют мерную колбу.'])
+qList(['Найди c, если 0,5 моль вещества в 2 л раствора.','Какую массу NaCl (M=58,5) нужно для 0,5 л раствора c = 1 моль/л?'])
+secNav('p50','p52')+readButton('p51'); wireReadBtn('p51'); }
function bp52(){ document.getElementById('p52-body').innerHTML =
hero(7,'§ 52 · Глава 6','Вода и растворы в жизни человека','H₂O','Вода — основа жизни и самый важный растворитель на планете.',['вода','очистка','значение'])
+makeCard('theory','Значение воды','§52','<p>Вода — <b>универсальный растворитель</b>: в ней растворяются соли, газы, питательные вещества. Растворы — это кровь, морская вода, почвенный раствор, напитки, лекарства.</p><p>Питьевую воду <b>очищают</b>: отстаиванием, фильтрованием, обеззараживанием, кипячением. Беречь чистую воду — задача каждого.</p>')
+'<div class="lat-grid"><div class="lat-card"><h4>В организме</h4><ul><li>кровь, лимфа — растворы</li><li>перенос веществ</li></ul></div><div class="lat-card"><h4>В природе</h4><ul><li>морская вода</li><li>почвенный раствор</li></ul></div><div class="lat-card"><h4>В быту</h4><ul><li>напитки, чай</li><li>лекарства</li></ul></div><div class="lat-card"><h4>Очистка</h4><ul><li>фильтрование</li><li>кипячение</li></ul></div></div>'
+rememberBox(['Вода — универсальный растворитель и основа жизни.','Воду очищают фильтрованием, отстаиванием, кипячением.'])
+qList(['Почему воду называют универсальным растворителем?','Назови способы очистки воды.'])
+secNav('p51','final1')+readButton('p52'); wireReadBtn('p52'); }
function bfinal(){ document.getElementById('final1-body').innerHTML =
hero('final','Финал главы 6','Босс: растворы','смеси · растворимость · w · c','Шесть интегрированных задач по всей главе. Победи босса — ачивка «Растворы освоены».')
+makeCard('rule','Шпаргалка главы',null,'<div class="formula-grid"><div class="fcard"><h3>Массовая доля</h3><div class="main-f">w = m(в-ва)/m(р-ра)</div></div><div class="fcard"><h3>Концентрация</h3><div class="main-f">c = n/V</div></div><div class="fcard"><h3>Растворимость</h3><div class="main-f">г / 100 г воды</div></div><div class="fcard highlight"><h3>Смеси</h3><div class="main-f">однород./неоднород.</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши все задачи — за каждую +5 XP, за победу — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p52',null); }
</script>
</body>
</html>
+622
View File
@@ -0,0 +1,622 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<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@400;700;800;900&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 src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<script src="/js/chem8_glossary.js" defer></script>
<style>
:root{
--bg:#fffbeb; --card:#fff;
--text:#1c1917; --muted:#78716c;
--border:#fde68a;
--pri:#d97706; --pri-d:#b45309;
--pri-soft:#fef3c7;
--c0:#d97706; --c0-d:#b45309; /* intro — amber */
--c1:#0d9488; --c1-d:#0f766e; /* гл.1 — teal */
--c2:#4f46e5; --c2-d:#4338ca; /* гл.2 — indigo */
--c3:#2563eb; --c3-d:#1d4ed8; /* гл.3 — blue */
--c4:#059669; --c4-d:#047857; /* гл.4 — green */
--c5:#ea580c; --c5-d:#c2410c; /* гл.5 — orange */
--c6:#0891b2; --c6-d:#0e7490; /* гл.6 — cyan */
--sh:0 4px 16px rgba(217,119,6,.10);
--sh-h:0 12px 36px rgba(217,119,6,.18);
}
html.dark{
--bg:#1c1410; --card:#271c14;
--text:#fef3c7; --muted:#d6b88a;
--border:#4a3520;
--pri-soft:rgba(217,119,6,.16);
}
*{margin:0;padding:0;box-sizing:border-box}
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}
/* HEADER */
.hdr{position:relative;background:linear-gradient(110deg,#92400e 0%,#d97706 55%,#fbbf24 100%);color:#fff;padding:32px 24px 28px;overflow:hidden;border-bottom:2px solid rgba(254,243,199,.18)}
.hdr::before{content:'ХИМИЯ';position:absolute;right:-14px;top:-18%;font-family:'Outfit',sans-serif;font-size:clamp(5rem,16vw,13rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(254,243,199,.14);line-height:1;pointer-events:none;user-select:none}
.hdr-inner{position:relative;z-index:1;max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:18px;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 h1{font-family:'Outfit',sans-serif;font-size:1.85rem;font-weight:900;letter-spacing:-.01em}
.hdr-sub{font-size:.92rem;opacity:.9;margin-top:4px}
.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)}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
main{max-width:1100px;margin:0 auto;padding:32px 24px 60px}
/* OVERALL PROGRESS */
.prog-overall{background:linear-gradient(135deg,var(--pri-soft),rgba(251,191,36,.12));border:1px solid var(--border);border-radius:14px;padding:14px 18px;margin-bottom:28px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.po-icon{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',sans-serif;font-size:1.4rem;font-weight:900}
.po-text{flex:1;min-width:160px}
.po-label{font-size:.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
.po-bar{height:8px;background:rgba(217,119,6,.16);border-radius:5px;overflow:hidden;margin-top:6px}
.po-fill{height:100%;background:linear-gradient(90deg,var(--pri),#fbbf24);border-radius:5px;transition:width .5s}
.po-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',sans-serif;letter-spacing:.02em;box-shadow:0 4px 12px rgba(217,119,6,.24)}
/* CHAPTER GRID */
.ch-grid{display:grid;grid-template-columns:1fr;gap:18px;margin-bottom:30px}
@media(min-width:680px){.ch-grid{grid-template-columns:1fr 1fr}}
@media(min-width:1000px){.ch-grid{grid-template-columns:repeat(3,1fr)}}
.ch-card{background:var(--card);border:1.5px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:transform .2s,box-shadow .2s,border-color .2s;cursor:pointer;text-decoration:none;color:inherit}
.ch-card:hover{transform:translateY(-4px);box-shadow:var(--sh-h)}
.ch-cover{padding:22px 22px 18px;color:#fff;position:relative;overflow:hidden}
.ch-cover-wm{position:absolute;right:-4px;top:-14px;font-size:4rem;font-weight:900;font-family:'Outfit',sans-serif;line-height:1;color:rgba(255,255,255,.22);pointer-events:none;letter-spacing:-.03em}
.ch-num{display:inline-block;padding:4px 10px;background:rgba(255,255,255,.24);border-radius:99px;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;position:relative;z-index:1}
.ch-title{font-family:'Outfit',sans-serif;font-size:1.1rem;font-weight:800;letter-spacing:-.01em;position:relative;z-index:1;line-height:1.3}
.ch-range{font-size:.84rem;opacity:.9;margin-top:4px;position:relative;z-index:1;font-weight:500}
.ch-cover.cc0{background:linear-gradient(135deg,#92400e,#d97706 60%,#fbbf24)}
.ch-cover.cc1{background:linear-gradient(135deg,#134e4a,#0d9488 60%,#14b8a6)}
.ch-cover.cc2{background:linear-gradient(135deg,#3730a3,#4f46e5 60%,#818cf8)}
.ch-cover.cc3{background:linear-gradient(135deg,#1e3a8a,#2563eb 60%,#60a5fa)}
.ch-cover.cc4{background:linear-gradient(135deg,#064e3b,#059669 60%,#34d399)}
.ch-cover.cc5{background:linear-gradient(135deg,#9a3412,#ea580c 60%,#fb923c)}
.ch-cover.cc6{background:linear-gradient(135deg,#164e63,#0891b2 60%,#22d3ee)}
.ch-body{padding:16px 20px 18px;display:flex;flex-direction:column;flex:1}
.ch-desc{font-size:.88rem;color:var(--text);opacity:.84;flex:1;margin-bottom:12px;line-height:1.55}
.ch-prog{margin-bottom:12px}
.ch-prog-label{display:flex;justify-content:space-between;font-size:.74rem;color:var(--muted);font-weight:600;margin-bottom:4px}
.ch-prog-bar{height:6px;background:rgba(0,0,0,.07);border-radius:4px;overflow:hidden}
.ch-prog-fill{height:100%;border-radius:4px;transition:width .5s}
.ch-card.k0 .ch-prog-fill{background:linear-gradient(90deg,var(--c0),var(--c0-d))}
.ch-card.k1 .ch-prog-fill{background:linear-gradient(90deg,var(--c1),var(--c1-d))}
.ch-card.k2 .ch-prog-fill{background:linear-gradient(90deg,var(--c2),var(--c2-d))}
.ch-card.k3 .ch-prog-fill{background:linear-gradient(90deg,var(--c3),var(--c3-d))}
.ch-card.k4 .ch-prog-fill{background:linear-gradient(90deg,var(--c4),var(--c4-d))}
.ch-card.k5 .ch-prog-fill{background:linear-gradient(90deg,var(--c5),var(--c5-d))}
.ch-card.k6 .ch-prog-fill{background:linear-gradient(90deg,var(--c6),var(--c6-d))}
.ch-action{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-radius:11px;font-weight:700;font-size:.9rem;color:#fff;transition:filter .15s}
.ch-action:hover{filter:brightness(1.08)}
.ch-card.k0 .ch-action{background:linear-gradient(135deg,var(--c0),#fbbf24)}
.ch-card.k1 .ch-action{background:linear-gradient(135deg,var(--c1),#14b8a6)}
.ch-card.k2 .ch-action{background:linear-gradient(135deg,var(--c2),#818cf8)}
.ch-card.k3 .ch-action{background:linear-gradient(135deg,var(--c3),#60a5fa)}
.ch-card.k4 .ch-action{background:linear-gradient(135deg,var(--c4),#34d399)}
.ch-card.k5 .ch-action{background:linear-gradient(135deg,var(--c5),#fb923c)}
.ch-card.k6 .ch-action{background:linear-gradient(135deg,var(--c6),#22d3ee)}
/* COURSE FINAL (placeholder — наполняется в Phase 7) */
.final-wrap{margin:0 0 28px;background:var(--card);border:1.5px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:var(--sh)}
.final-head{padding:18px 22px;background:linear-gradient(135deg,#92400e 0%,#d97706 55%,#f59e0b 100%);color:#fff;cursor:pointer;display:flex;align-items:center;gap:14px;user-select:none;transition:filter .15s}
.final-head:hover{filter:brightness(1.06)}
.final-head-icon{width:46px;height:46px;border-radius:12px;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.final-head-icon svg{width:26px;height:26px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.final-head-text{flex:1;min-width:0}
.final-head-tag{display:inline-block;padding:3px 9px;background:rgba(255,255,255,.24);border-radius:99px;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}
.final-head-title{font-family:'Outfit',sans-serif;font-size:1.18rem;font-weight:800;letter-spacing:-.01em;line-height:1.25}
.final-head-sub{font-size:.84rem;opacity:.9;margin-top:2px}
.final-chevron{flex-shrink:0;transition:transform .25s}
.final-chevron svg{width:24px;height:24px;stroke:#fff;fill:none;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
.final-wrap.open .final-chevron{transform:rotate(180deg)}
.final-body{display:none;padding:22px}
.final-wrap.open .final-body{display:block}
.fin-placeholder{padding:24px 18px;background:linear-gradient(135deg,var(--pri-soft),rgba(251,191,36,.08));border:1.5px dashed var(--pri);border-radius:14px;text-align:center;color:var(--text)}
.fin-placeholder h3{font-family:'Outfit',sans-serif;color:var(--pri-d);margin-bottom:8px;font-size:1.1rem}
.fin-placeholder p{color:var(--muted);font-size:.92rem;line-height:1.55}
.fin-section-title{font-family:'Outfit',sans-serif;font-size:1.12rem;font-weight:800;color:var(--text);margin:8px 0 14px;display:flex;align-items:center;gap:9px}
.fin-section-title svg{width:20px;height:20px;stroke:var(--pri);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.cheat-grid{display:grid;grid-template-columns:1fr;gap:14px;margin-bottom:28px}
@media(min-width:680px){.cheat-grid{grid-template-columns:1fr 1fr}}
@media(min-width:1000px){.cheat-grid{grid-template-columns:repeat(3,1fr)}}
.cheat-card{border:1.5px solid var(--border);border-radius:13px;padding:14px 16px;background:var(--card);position:relative;overflow:hidden}
.cheat-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px}
.cheat-card.c1::before{background:#d97706}.cheat-card.c2::before{background:#0d9488}.cheat-card.c3::before{background:#4f46e5}.cheat-card.c4::before{background:#2563eb}.cheat-card.c5::before{background:#059669}.cheat-card.c6::before{background:#ea580c}.cheat-card.c7::before{background:#0891b2}
.cheat-head{display:flex;align-items:center;gap:9px;margin-bottom:9px;padding-left:6px}
.cheat-badge{font-size:.68rem;font-weight:800;padding:2px 8px;border-radius:99px;color:#fff;letter-spacing:.04em;text-transform:uppercase}
.cheat-card.c1 .cheat-badge{background:#d97706}.cheat-card.c2 .cheat-badge{background:#0d9488}.cheat-card.c3 .cheat-badge{background:#4f46e5}.cheat-card.c4 .cheat-badge{background:#2563eb}.cheat-card.c5 .cheat-badge{background:#059669}.cheat-card.c6 .cheat-badge{background:#ea580c}.cheat-card.c7 .cheat-badge{background:#0891b2}
.cheat-title{font-weight:800;color:var(--text);font-size:.96rem}
.cheat-list{list-style:none;padding-left:6px;margin:0}
.cheat-list li{padding:6px 0;border-bottom:1px dashed var(--border);font-size:.9rem;line-height:1.5;color:var(--text)}
.cheat-list li:last-child{border-bottom:0}
.boss-overall-bar{background:linear-gradient(135deg,var(--pri-soft),rgba(251,191,36,.08));border:1px solid var(--border);border-radius:12px;padding:13px 16px;margin:6px 0 18px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.boss-overall-bar .lab{font-weight:700;font-size:.95rem;color:var(--text);min-width:200px}
.boss-overall-bar .bar{flex:1;min-width:160px;height:9px;background:rgba(217,119,6,.16);border-radius:5px;overflow:hidden}
.boss-overall-bar .fill{height:100%;background:linear-gradient(90deg,var(--pri),#fbbf24);transition:width .5s;border-radius:5px}
.boss-card{background:var(--card);border:2px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;transition:border-color .35s,box-shadow .35s}
.boss-card.solved{border-color:#10b981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
.boss-head{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap}
.boss-tag{font-size:.68rem;font-weight:800;padding:3px 9px;border-radius:99px;background:var(--pri-soft);color:var(--pri-d);letter-spacing:.04em;text-transform:uppercase}
html.dark .boss-tag{color:var(--pri-l)}
.boss-title{font-family:'Outfit',sans-serif;font-weight:800;color:var(--text);font-size:1rem;flex:1;min-width:0}
.boss-q{padding:12px 14px;background:var(--pri-soft);border-radius:10px;font-size:.95rem;line-height:1.55;margin-bottom:10px;color:var(--text)}
.boss-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
.boss-input{padding:8px 12px;border:1.5px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-family:'JetBrains Mono',monospace;width:130px;text-align:center;font-size:.95rem}
.boss-input:focus{outline:0;border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-soft)}
.boss-btn{padding:8px 16px;border-radius:9px;background:var(--card);color:var(--text);border:1.5px solid var(--border);font-weight:700;font-size:.88rem;cursor:pointer;font-family:inherit;transition:.15s}
.boss-btn:hover{background:var(--pri-soft);border-color:var(--pri)}
.boss-btn.primary{background:linear-gradient(135deg,var(--pri),#fbbf24);color:#fff;border-color:transparent}
.boss-fb{padding:10px 14px;border-radius:9px;font-weight:600;font-size:.88rem;margin-top:8px;display:none;line-height:1.45}
.boss-fb.ok{display:block;background:#d1fae5;color:#065f46;border-left:4px solid #10b981}
.boss-fb.fail{display:block;background:#fee2e2;color:#7f1d1d;border-left:4px solid #dc2626}
html.dark .boss-fb.ok{background:rgba(16,185,129,.18);color:#a7f3d0}html.dark .boss-fb.fail{background:rgba(220,38,38,.18);color:#fecaca}
.boss-hint-txt{margin-top:8px;padding:9px 13px;background:rgba(245,158,11,.12);border-left:3px solid #f59e0b;border-radius:6px;font-size:.86rem;color:var(--text);display:none;line-height:1.5}
.boss-hint-txt.show{display:block}
.final-cta{margin-top:24px;padding:18px 20px;border-radius:14px;background:linear-gradient(135deg,#fef3c7,#fde68a);border:1.5px solid #fbbf24;display:none;align-items:center;gap:14px;flex-wrap:wrap}
.final-cta.show{display:flex}
html.dark .final-cta{background:linear-gradient(135deg,rgba(245,158,11,.18),rgba(217,119,6,.15));border-color:#d97706}
.final-cta-icon{width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg,#fbbf24,#f59e0b);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.final-cta-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.final-cta-txt{flex:1;min-width:180px}
.final-cta-title{font-weight:800;color:#92400e;font-size:1.05rem;font-family:'Outfit',sans-serif}
html.dark .final-cta-title{color:#fde68a}
.final-cta-sub{font-size:.86rem;color:#78350f;margin-top:2px}html.dark .final-cta-sub{color:#fcd34d}
.final-cta-btn{padding:10px 18px;border-radius:10px;background:linear-gradient(135deg,var(--pri),#f59e0b);color:#fff;text-decoration:none;font-weight:800;font-size:.9rem;display:inline-flex;align-items:center;gap:7px;transition:filter .15s}
.final-cta-btn:hover{filter:brightness(1.1)}
.final-cta-btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
/* ACHIEVEMENT STRIP */
.ach-strip{background:var(--card);border:1.5px solid var(--border);border-radius:16px;padding:18px 22px;margin-bottom:28px;display:flex;align-items:center;gap:16px;transition:border-color .4s,box-shadow .4s}
.ach-strip.lit{border-color:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.18)}
.ach-icon{width:52px;height:52px;border-radius:14px;background:rgba(0,0,0,.06);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .4s}
.ach-strip.lit .ach-icon{background:linear-gradient(135deg,#fbbf24,#f59e0b)}
.ach-icon svg{width:28px;height:28px;stroke:var(--muted);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.ach-strip.lit .ach-icon svg{stroke:#fff}
.ach-text{flex:1}
.ach-title{font-weight:800;font-size:1.02rem;color:var(--text)}
.ach-sub{font-size:.85rem;color:var(--muted);margin-top:2px}
.ach-strip.lit .ach-title{color:#92400e}
html.dark .ach-strip.lit .ach-title{color:#fde68a}
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<header class="hdr">
<div class="hdr-inner">
<div>
<a href="/textbooks" class="hdr-back">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
К каталогу
</a>
</div>
<div>
<h1>Химия — 8 класс</h1>
<div class="hdr-sub">Количественные понятия, классы соединений, периодический закон, строение атома, химическая связь, ОВР, растворы. 7 разделов, 52 параграфа.</div>
</div>
<div class="hdr-side">
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span id="theme-lab">Тёмная</span>
</button>
</div>
</div>
</header>
<main>
<section class="prog-overall">
<div class="po-icon">Х</div>
<div class="po-text">
<div class="po-label">Общий прогресс по курсу</div>
<div id="overall-text" style="font-size:1.05rem;font-weight:700">Загрузка...</div>
<div class="po-bar"><div id="overall-fill" class="po-fill" style="width:0%"></div></div>
</div>
<div id="hero-xp-badge" class="po-xp" style="display:none" data-gamified>0 XP</div>
</section>
<div class="ch-grid">
<a href="/textbook/chemistry-8-intro" class="ch-card k0" id="ch-1">
<div class="ch-cover cc0">
<div class="ch-cover-wm">mol</div>
<div class="ch-num">Вводный раздел</div>
<div class="ch-title">Количественные понятия в химии</div>
<div class="ch-range">&sect;1&ndash;&sect;9 &middot; ПР&nbsp;1</div>
</div>
<div class="ch-body">
<div class="ch-desc">Атомы и элементы, простые и сложные вещества, химическое количество вещества, моль и постоянная Авогадро, молярная масса и объём газов, расчёты по массе, объёму и уравнениям реакций.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-1">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-1" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-1">Открыть раздел</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch1" class="ch-card k1" id="ch-2">
<div class="ch-cover cc1">
<div class="ch-cover-wm">OH&minus;</div>
<div class="ch-num">Глава 1</div>
<div class="ch-title">Важнейшие классы неорганических соединений</div>
<div class="ch-range">&sect;10&ndash;&sect;23 &middot; 2 лаб &middot; ПР&nbsp;2,3</div>
</div>
<div class="ch-body">
<div class="ch-desc">Оксиды, кислоты, основания и соли: состав, классификация, химические свойства, получение и применение; генетическая связь между классами неорганических веществ.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-2">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-2" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-2">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch2" class="ch-card k2" id="ch-3">
<div class="ch-cover cc2">
<div class="ch-cover-wm">&#8470;</div>
<div class="ch-num">Глава 2</div>
<div class="ch-title">Периодический закон и периодическая система</div>
<div class="ch-range">&sect;24&ndash;&sect;28 &middot; 1 лаб</div>
</div>
<div class="ch-body">
<div class="ch-desc">Систематизация элементов, амфотерность, естественные семейства элементов, периодический закон Д. И. Менделеева и строение периодической системы.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-3">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-3" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-3">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch3" class="ch-card k3" id="ch-4">
<div class="ch-cover cc3">
<div class="ch-cover-wm">e&minus;</div>
<div class="ch-num">Глава 3</div>
<div class="ch-title">Строение атома</div>
<div class="ch-range">&sect;29&ndash;&sect;35</div>
</div>
<div class="ch-body">
<div class="ch-desc">Строение атома и атомный номер, массовое число и нуклиды, изотопы и радиоактивность, электронное облако и орбиталь, электронные оболочки, периодичность свойств.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-4">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-4" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-4">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch4" class="ch-card k4" id="ch-5">
<div class="ch-cover cc4">
<div class="ch-cover-wm">H&#8322;O</div>
<div class="ch-num">Глава 4</div>
<div class="ch-title">Химическая связь</div>
<div class="ch-range">&sect;36&ndash;&sect;41 &middot; 1 лаб</div>
</div>
<div class="ch-body">
<div class="ch-desc">Природа химической связи, ковалентная связь (неполярная и полярная, электроотрицательность), ионная и металлическая связь, межмолекулярное взаимодействие, кристаллические решётки.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-5">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-5" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-5">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch5" class="ch-card k5" id="ch-6">
<div class="ch-cover cc5">
<div class="ch-cover-wm">O&#8322;</div>
<div class="ch-num">Глава 5</div>
<div class="ch-title">Окислительно-восстановительные реакции</div>
<div class="ch-range">&sect;42&ndash;&sect;45</div>
</div>
<div class="ch-body">
<div class="ch-desc">Степень окисления, процессы окисления и восстановления, окислительно-восстановительные реакции и метод электронного баланса, ОВР вокруг нас.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-6">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-6" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-6">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
<a href="/textbook/chemistry-8-ch6" class="ch-card k6" id="ch-7">
<div class="ch-cover cc6">
<div class="ch-cover-wm">aq</div>
<div class="ch-num">Глава 6</div>
<div class="ch-title">Растворы</div>
<div class="ch-range">&sect;46&ndash;&sect;52 &middot; ПР&nbsp;4</div>
</div>
<div class="ch-body">
<div class="ch-desc">Смеси веществ, растворение в воде, характеристики растворимости, качественные и количественные характеристики состава растворов, массовая доля и молярная концентрация, вода в жизни человека.</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-7">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-7" style="width:0%"></div></div>
</div>
<div class="ch-action"><span id="btn-7">Открыть главу</span><svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></div>
</div>
</a>
</div>
<section class="final-wrap" id="course-final">
<div class="final-head" id="final-head" tabindex="0" role="button" aria-expanded="false" aria-controls="final-body">
<div class="final-head-icon">
<svg viewBox="0 0 24 24"><path d="M7 4h10v6a5 5 0 0 1-10 0V4z"/><path d="M5 4h2v2H5a2 2 0 0 1 0-4M19 4h-2v2h2a2 2 0 0 0 0-4M9 20h6M12 15v5"/></svg>
</div>
<div class="final-head-text">
<div class="final-head-tag">Финал курса</div>
<div class="final-head-title">Босс-проверка по всему курсу</div>
<div class="final-head-sub">Шпаргалка курса и интегрированные боссы по всем 7 разделам. Победи всех — получи «Химик 8 класса».</div>
</div>
<div class="final-chevron"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></div>
</div>
<div class="final-body" id="final-body">
<div class="fin-section-title"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg> Шпаргалка курса</div>
<div class="cheat-grid">
<div class="cheat-card c1"><div class="cheat-head"><span class="cheat-badge">Вводный</span><span class="cheat-title">Количество вещества</span></div><ul class="cheat-list"><li>$n=\dfrac{m}{M}$, $\;M=M_r$</li><li>$V=n\cdot22{,}4$ л/моль (н.у.)</li><li>$N=n\cdot6{,}02\cdot10^{23}$</li><li>Расчёт по уравнению — по коэффициентам</li></ul></div>
<div class="cheat-card c2"><div class="cheat-head"><span class="cheat-badge">Гл. 1</span><span class="cheat-title">Классы соединений</span></div><ul class="cheat-list"><li>Оксиды: осн./кисл./амфот.</li><li>Кислоты: основность = число H</li><li>Основания: щёлочи / нераств.</li><li>Соль + щёлочь/кислота/Me (РИО)</li></ul></div>
<div class="cheat-card c3"><div class="cheat-head"><span class="cheat-badge">Гл. 2</span><span class="cheat-title">Периодический закон</span></div><ul class="cheat-list"><li>Период = число слоёв</li><li>Группа = внешние электроны</li><li>Амфотерность: Zn(OH)₂, Al(OH)₃</li><li>Семейства: щелочные, галогены</li></ul></div>
<div class="cheat-card c4"><div class="cheat-head"><span class="cheat-badge">Гл. 3</span><span class="cheat-title">Строение атома</span></div><ul class="cheat-list"><li>$A=Z+N$; $Z=p^+=e^-$</li><li>Изотопы — разный N</li><li>Слой: $2n^2$ электронов</li><li>Свойства — внешний слой</li></ul></div>
<div class="cheat-card c5"><div class="cheat-head"><span class="cheat-badge">Гл. 4</span><span class="cheat-title">Химическая связь</span></div><ul class="cheat-list"><li>Ковалентная — общие пары</li><li>Ионная — передача e⁻</li><li>Металлическая — электронный газ</li><li>Решётка → свойства</li></ul></div>
<div class="cheat-card c6"><div class="cheat-head"><span class="cheat-badge">Гл. 5</span><span class="cheat-title">ОВР</span></div><ul class="cheat-list"><li>С.о.: H +1, O 2, Σ=0</li><li>Окисление −e⁻; восстановление +e⁻</li><li>Баланс: отдано = принято e⁻</li></ul></div>
<div class="cheat-card c7"><div class="cheat-head"><span class="cheat-badge">Гл. 6</span><span class="cheat-title">Растворы</span></div><ul class="cheat-list"><li>$w=\dfrac{m_{в-ва}}{m_{р-ра}}$</li><li>$c=\dfrac{n}{V}$ (моль/л)</li><li>Растворимость: г / 100 г воды</li><li>Смеси: однород./неоднород.</li></ul></div>
</div>
<div class="fin-section-title"><svg viewBox="0 0 24 24"><path d="M14.5 3.5l-5 5L4 4l1.5 6L3 12l5 1 1 5 2.5-2.5 6 1.5-4.5-5.5 5-5"/></svg> 10 интегрированных боссов</div>
<div class="boss-overall-bar"><div class="lab" id="fin-boss-lab">Боссов побеждено: 0 / 10</div><div class="bar"><div class="fill" id="fin-boss-fill" style="width:0%"></div></div></div>
<div id="fin-bosses-container"></div>
<div class="final-cta" id="final-cta">
<div class="final-cta-icon"><svg viewBox="0 0 24 24"><path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6M6 9h12"/><path d="M9 21h6M12 15v6"/></svg></div>
<div class="final-cta-txt"><div class="final-cta-title">Курс «Химия 8» пройден!</div><div class="final-cta-sub">Вы прошли итоговую проверку по всем 7 разделам. +150 XP, ачивка «Химик 8 класса» получена.</div></div>
<a href="/textbooks" class="final-cta-btn">К каталогу <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></a>
</div>
</div>
</section>
<div class="ach-strip" id="ach-strip">
<div class="ach-icon">
<svg viewBox="0 0 24 24"><path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6M6 9h12"/><path d="M9 21h6M12 15v6"/></svg>
</div>
<div class="ach-text">
<div class="ach-title">Химик 8 класса</div>
<div class="ach-sub" id="ach-sub">Изучите все 52 параграфа курса, чтобы получить достижение.</div>
</div>
</div>
</main>
<footer class="foot">
Интерактивный учебник «Химия — 8 класс» &middot; Шиманович, Красицкий, Сечко, Хвалюк &middot; LearnSpace
</footer>
<script>
'use strict';
/* THEME */
(function(){
var saved = localStorage.getItem('chemistry8_theme') || localStorage.getItem('theme') || 'light';
if (saved === 'dark') document.documentElement.classList.add('dark');
var lab = document.getElementById('theme-lab');
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 ? 'Светлая' : 'Тёмная';
});
})();
/* PROGRESS */
var TOTAL = 52;
var CH_PARA = {
'chemistry-8-intro': 9,
'chemistry-8-ch1': 14,
'chemistry-8-ch2': 5,
'chemistry-8-ch3': 7,
'chemistry-8-ch4': 6,
'chemistry-8-ch5': 4,
'chemistry-8-ch6': 7
};
var CH_IDX = {
'chemistry-8-intro': 1,
'chemistry-8-ch1': 2,
'chemistry-8-ch2': 3,
'chemistry-8-ch3': 4,
'chemistry-8-ch4': 5,
'chemistry-8-ch5': 6,
'chemistry-8-ch6': 7
};
function setChProg(idx, readCount, total) {
var pct = total ? Math.round(readCount * 100 / total) : 0;
var labelEl = document.getElementById('prog-' + idx);
var fillEl = document.getElementById('fill-' + idx);
var btnEl = document.getElementById('btn-' + idx);
if (labelEl) labelEl.textContent = pct + '%';
if (fillEl) fillEl.style.width = pct + '%';
if (btnEl) {
var base = idx === 1 ? 'раздел' : 'главу';
if (readCount > 0 && readCount < total) btnEl.textContent = 'Продолжить';
else if (readCount >= total) btnEl.textContent = 'Открыть снова';
else btnEl.textContent = 'Открыть ' + base;
}
return pct;
}
var FIN_ACH_KEY = 'chemistry8_course_master';
function renderProgress(children) {
var totalRead = 0;
for (var i = 0; i < children.length; i++) {
var ch = children[i];
var idx = CH_IDX[ch.slug];
if (!idx) continue;
var read = ch.progress ? ch.progress.read.length : 0;
var total = ch.para_count || CH_PARA[ch.slug] || 1;
totalRead += read;
setChProg(idx, read, total);
}
var pct = Math.round(totalRead * 100 / TOTAL);
var overallEl = document.getElementById('overall-text');
var fillEl = document.getElementById('overall-fill');
if (overallEl) overallEl.textContent = totalRead + ' из ' + TOTAL + ' параграфов \xb7 ' + pct + '%';
if (fillEl) fillEl.style.width = pct + '%';
var xpBadge = document.getElementById('hero-xp-badge');
var xp = parseInt(localStorage.getItem('chemistry8_xp') || '0', 10) || 0;
if (xpBadge && xp > 0) {
xpBadge.style.display = '';
xpBadge.textContent = xp + ' XP';
}
var mastered = localStorage.getItem(FIN_ACH_KEY) === '1';
if (totalRead >= TOTAL || mastered) {
var strip = document.getElementById('ach-strip');
var sub = document.getElementById('ach-sub');
if (strip) strip.classList.add('lit');
if (sub) sub.textContent = mastered
? 'Выполнено! Вы — Химик 8 класса.'
: 'Выполнено! Вы изучили весь курс химии 8 класса.';
}
}
/* ===== ФИНАЛ КУРСА: 10 интегрированных боссов ===== */
var FIN_BOSS_KEY = 'chemistry8_course_bosses';
var FIN_BOSSES = [
{ n:1, tag:'Вводный', title:'Относительная молекулярная масса', q:'Чему равна $M_r(\\text{Ca(OH)}_2)$?', hint:'$40 + 2\\cdot(16+1) = 74$.', ans:74 },
{ n:2, tag:'Вводный', title:'Количество вещества', q:'Сколько моль в $49$ г $\\text{H}_2\\text{SO}_4$ ($M=98$)?', hint:'$n=m/M=49/98=0{,}5$.', ans:0.5, tol:0.02, step:'0.01' },
{ n:3, tag:'Вводный + Гл.1', title:'Расчёт по уравнению', q:'$\\text{CaCO}_3 \\to \\text{CaO} + \\text{CO}_2$. Дано $m(\\text{CaCO}_3)=50$ г ($M=100$). Какой объём $\\text{CO}_2$ (л, н.у.)?', hint:'$n=0{,}5$ моль → $V=0{,}5\\cdot22{,}4=11{,}2$ л.', ans:11.2, tol:0.1, step:'0.1' },
{ n:4, tag:'Гл.1', title:'Качественная реакция', q:'В реакции $\\text{BaCl}_2+\\text{Na}_2\\text{SO}_4$ выпадает осадок $\\text{BaSO}_4$. Чему равна его $M_r$?', hint:'$137+32+4\\cdot16=233$.', ans:233 },
{ n:5, tag:'Гл.1', title:'Ряд активности', q:'Сколько из металлов Cu, Zn, Fe, Ag вытесняют $\\text{H}_2$ из соляной кислоты?', hint:'До водорода стоят Zn и Fe → 2.', ans:2 },
{ n:6, tag:'Гл.2', title:'Периодическая система', q:'Номер группы хлора (число внешних электронов)?', hint:'Cl — VII группа → 7.', ans:7 },
{ n:7, tag:'Гл.3', title:'Строение атома', q:'Сколько нейтронов в атоме $^{39}\\text{K}$ ($Z=19$)?', hint:'$N=A-Z=39-19=20$.', ans:20 },
{ n:8, tag:'Гл.4 + 5', title:'Степень окисления', q:'Чему равна степень окисления серы в $\\text{H}_2\\text{SO}_4$?', hint:'$2\\cdot(+1)+S+4\\cdot(-2)=0 \\Rightarrow S=+6$.', ans:6 },
{ n:9, tag:'Гл.5', title:'Электронный баланс', q:'Сколько электронов отдаёт алюминий при окислении $\\text{Al}^0 \\to \\text{Al}^{+3}$?', hint:'3 электрона.', ans:3 },
{ n:10, tag:'Гл.6', title:'Массовая доля', q:'В $80$ г воды растворили $20$ г соли. Чему равна массовая доля (%)?', hint:'$w=20/(20+80)\\cdot100=20\\%$.', ans:20 }
];
function loadFinBossState(){ try{ return JSON.parse(localStorage.getItem(FIN_BOSS_KEY)||'{}')||{}; }catch(e){ return {}; } }
function saveFinBossState(s){ try{ localStorage.setItem(FIN_BOSS_KEY, JSON.stringify(s)); }catch(e){} }
function finRenderKatex(root){ if(typeof window.renderMathInElement!=='function')return; try{ window.renderMathInElement(root,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false}); }catch(e){} }
function updateFinBossBar(state){ var won=0; for(var k in state) if(state[k])won++; var lab=document.getElementById('fin-boss-lab'),fill=document.getElementById('fin-boss-fill'); if(lab)lab.textContent='Боссов побеждено: '+won+' / '+FIN_BOSSES.length; if(fill)fill.style.width=Math.round(won*100/FIN_BOSSES.length)+'%'; return won; }
function maybeUnlockMaster(state){
if(localStorage.getItem(FIN_ACH_KEY)==='1')return;
var won=0; for(var k in state) if(state[k])won++; if(won<FIN_BOSSES.length)return;
localStorage.setItem(FIN_ACH_KEY,'1');
var xp=parseInt(localStorage.getItem('chemistry8_xp')||'0',10)||0; localStorage.setItem('chemistry8_xp',String(xp+150));
try{ if(window.LS&&window.LS.xp&&window.LS.xp.add) window.LS.xp.add(150,'chemistry8-master'); }catch(e){}
try{ if(window.confetti) window.confetti({particleCount:220,spread:110,origin:{y:.6}}); }catch(e){}
var strip=document.getElementById('ach-strip'),sub=document.getElementById('ach-sub');
if(strip)strip.classList.add('lit'); if(sub)sub.textContent='Выполнено! Вы — Химик 8 класса.';
var cta=document.getElementById('final-cta'); if(cta)cta.classList.add('show');
var xb=document.getElementById('hero-xp-badge'); if(xb){ xb.style.display=''; xb.textContent=(parseInt(localStorage.getItem('chemistry8_xp')||'0',10)||0)+' XP'; }
}
function buildFinBoss(b,state){
var solved=!!state[b.n], step=b.step||'1';
var dispAns=(typeof b.ans==='number'&&step!=='1')?b.ans:b.ans;
return '<div class="boss-card'+(solved?' solved':'')+'" id="fb-'+b.n+'-card">'
+'<div class="boss-head"><span class="boss-tag">'+b.tag+'</span><span class="boss-title">Босс '+b.n+'. '+b.title+'</span></div>'
+'<div class="boss-q">'+b.q+'</div>'
+'<div class="boss-row"><input type="number" step="'+step+'" class="boss-input" id="fb-'+b.n+'-inp" placeholder="число"'+(solved?' value="'+dispAns+'" disabled':'')+'>'
+'<button class="boss-btn primary" id="fb-'+b.n+'-go"'+(solved?' disabled':'')+'>Атаковать</button>'
+'<button class="boss-btn" id="fb-'+b.n+'-hint">Подсказка</button></div>'
+'<div class="boss-hint-txt" id="fb-'+b.n+'-ht">'+b.hint+'</div>'
+'<div class="boss-fb'+(solved?' ok':'')+'" id="fb-'+b.n+'-fbk">'+(solved?'Победа! Босс повержен.':'')+'</div></div>';
}
function bindFinBoss(b){
var go=document.getElementById('fb-'+b.n+'-go'), hint=document.getElementById('fb-'+b.n+'-hint'),
inp=document.getElementById('fb-'+b.n+'-inp'), fbk=document.getElementById('fb-'+b.n+'-fbk'),
ht=document.getElementById('fb-'+b.n+'-ht'), card=document.getElementById('fb-'+b.n+'-card');
if(!go)return;
if(hint)hint.addEventListener('click',function(){ if(ht)ht.classList.toggle('show'); });
var state=loadFinBossState(); if(state[b.n])return;
go.addEventListener('click',function(){
var v=parseFloat((inp.value||'').replace(',','.'));
if(isNaN(v)){ fbk.className='boss-fb fail'; fbk.textContent='Введите число.'; return; }
var tol=(typeof b.tol==='number')?b.tol:1e-9;
if(Math.abs(v-b.ans)<=tol){
fbk.className='boss-fb ok'; fbk.textContent='Победа! +15 XP. Босс повержен.'; card.classList.add('solved'); go.disabled=true; inp.disabled=true;
var s=loadFinBossState(); if(!s[b.n]){ s[b.n]=true; saveFinBossState(s);
var xp=parseInt(localStorage.getItem('chemistry8_xp')||'0',10)||0; localStorage.setItem('chemistry8_xp',String(xp+15));
try{ if(window.LS&&window.LS.xp&&window.LS.xp.add) window.LS.xp.add(15,'chemistry8-fin-boss-'+b.n); }catch(e){}
var xb=document.getElementById('hero-xp-badge'); if(xb){ xb.style.display=''; xb.textContent=(parseInt(localStorage.getItem('chemistry8_xp')||'0',10)||0)+' XP'; }
updateFinBossBar(s); maybeUnlockMaster(s);
}
} else { fbk.className='boss-fb fail'; fbk.textContent='Не то. Перепроверь решение и попробуй снова.'; }
});
inp.addEventListener('keydown',function(e){ if(e.key==='Enter'){ e.preventDefault(); go.click(); } });
}
var FIN_BOSSES_RENDERED=false;
function renderFinBosses(){
if(FIN_BOSSES_RENDERED)return;
var cont=document.getElementById('fin-bosses-container'); if(!cont)return;
var state=loadFinBossState(), html='';
for(var i=0;i<FIN_BOSSES.length;i++) html+=buildFinBoss(FIN_BOSSES[i],state);
cont.innerHTML=html;
for(var j=0;j<FIN_BOSSES.length;j++) bindFinBoss(FIN_BOSSES[j]);
finRenderKatex(document.getElementById('course-final'));
updateFinBossBar(state);
if(localStorage.getItem(FIN_ACH_KEY)==='1'){ var cta=document.getElementById('final-cta'); if(cta)cta.classList.add('show'); }
FIN_BOSSES_RENDERED=true;
}
/* FINAL ACCORDION */
(function bindFinalAccordion(){
var head = document.getElementById('final-head');
var wrap = document.getElementById('course-final');
if (!head || !wrap) return;
function toggle(){
var willOpen = !wrap.classList.contains('open');
wrap.classList.toggle('open');
head.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
if (willOpen) { renderFinBosses(); finRenderKatex(wrap); }
}
head.addEventListener('click', toggle);
head.addEventListener('keydown', function(e){
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
})();
function loadProgress() {
if (typeof window.LS === 'undefined' || typeof window.LS.api !== 'function') {
renderProgress([]);
return;
}
window.LS.api('/api/textbooks/chemistry-8/children')
.then(function(data) {
if (data && data.children) renderProgress(data.children);
else renderProgress([]);
})
.catch(function() { renderProgress([]); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadProgress);
} else {
loadProgress();
}
window.addEventListener('focus', loadProgress);
</script>
</body>
</html>
+393
View File
@@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<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&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">
<link rel="stylesheet" href="/css/chem8-textbook.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 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>
<script src="/js/chem8_glossary.js" defer></script>
<script src="/js/chem8_intro_widgets.js" defer></script>
<script src="/js/chem8_engine.js" defer></script>
</head>
<body>
<header class="hdr">
<div class="hdr-row">
<div>
<h1>Химия 8 · Вводный раздел</h1>
<div class="hdr-sub">Количественные понятия: атомы, формулы, моль, молярная масса и объём, расчёты по уравнениям</div>
</div>
<div class="hdr-side">
<a href="/textbook/chemistry-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К разделам</a>
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
</div>
</div>
</header>
<main class="main">
<div class="col-main">
<section class="hero">
<h2>Химия начинается со счёта</h2>
<p>Прежде чем изучать вещества и реакции, химик учится их «считать»: переходить от массы к числу частиц, от объёма газа — к количеству вещества, рассчитывать продукты реакции по уравнению. Эти количественные понятия — фундамент всего курса.</p>
<div class="hero-row">
<button class="btn-primary" onclick="goTo('p1')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать § 1</button>
<div class="hero-progress">
<span class="hp-label">Прогресс раздела</span>
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
<span id="hero-hp-text" class="hp-text">0%</span>
</div>
<div id="hero-xp-badge" class="hero-xp-badge"></div>
</div>
</section>
<section class="psel">
<div class="psel-title">Параграфы раздела</div>
<div id="psel-grid" class="psel-grid"></div>
</section>
<section id="sec-p1" class="sec"><div class="sec-header"><span class="sec-num">§ 1</span><h2 class="sec-h">Атомы. Химические элементы. Относительная атомная масса</h2></div><div id="p1-body"></div></section>
<section id="sec-p2" class="sec"><div class="sec-header"><span class="sec-num">§ 2</span><h2 class="sec-h">Молекулы. Простые и сложные вещества. Формулы. $M_r$</h2></div><div id="p2-body"></div></section>
<section id="sec-p3" class="sec"><div class="sec-header"><span class="sec-num">§ 3</span><h2 class="sec-h">Химическое количество вещества</h2></div><div id="p3-body"></div></section>
<section id="sec-p4" class="sec"><div class="sec-header"><span class="sec-num">§ 4</span><h2 class="sec-h">Моль. Постоянная Авогадро</h2></div><div id="p4-body"></div></section>
<section id="sec-p5" class="sec"><div class="sec-header"><span class="sec-num">§ 5</span><h2 class="sec-h">Молярная масса. Молярный объём газов</h2></div><div id="p5-body"></div></section>
<section id="sec-p6" class="sec"><div class="sec-header"><span class="sec-num">§ 6</span><h2 class="sec-h">Вычисление $n$ по массе и массы по $n$</h2></div><div id="p6-body"></div></section>
<section id="sec-p7" class="sec"><div class="sec-header"><span class="sec-num">§ 7</span><h2 class="sec-h">Вычисление количества и объёма газа</h2></div><div id="p7-body"></div></section>
<section id="sec-pr1" class="sec"><div class="sec-header"><span class="sec-num">ПР 1</span><h2 class="sec-h">Практическая работа: химическое количество вещества</h2></div><div id="pr1-body"></div></section>
<section id="sec-p8" class="sec"><div class="sec-header"><span class="sec-num">§ 8</span><h2 class="sec-h">Химические реакции</h2></div><div id="p8-body"></div></section>
<section id="sec-p9" class="sec"><div class="sec-header"><span class="sec-num">§ 9</span><h2 class="sec-h">Количественные расчёты по уравнениям реакций</h2></div><div id="p9-body"></div></section>
<section id="sec-final1" class="sec"><div class="sec-header"><span class="sec-num"></span><h2 class="sec-h">Финал раздела</h2></div><div id="final1-body"></div></section>
</div>
<aside class="col-side"><div id="sidebar-content"></div></aside>
</main>
<footer class="foot">Интерактивный учебник «Химия — 8 класс» · Вводный раздел · «Количественные понятия в химии» · LearnSpace</footer>
<div id="ach-popup" class="ach-popup"><svg viewBox="0 0 24 24"><polygon points="12 2 22 20 2 20"/></svg><span id="ach-text">Достижение!</span></div>
<script>
'use strict';
/* ── конфигурация раздела (читается движком chem8_engine.js) ── */
window.CHEM8_CFG = { slug:'chemistry-8-intro', themeKey:'chemistry8_theme', xpKey:'chemistry8_xp',
progKey:'chemistry8_intro_progress', achKey:'chemistry8_intro_ach' };
window.PARAS = [
{id:'p1',num:'§ 1',name:'Атомы. Атомная масса',sub:'элемент · $A_r$'},
{id:'p2',num:'§ 2',name:'Формулы. $M_r$',sub:'простые/сложные'},
{id:'p3',num:'§ 3',name:'Химическое количество',sub:'$n$, моль'},
{id:'p4',num:'§ 4',name:'Моль. Авогадро',sub:'$N=n\\,N_A$'},
{id:'p5',num:'§ 5',name:'Молярная масса и объём',sub:'$M$ · $V_m$'},
{id:'p6',num:'§ 6',name:'Треугольник n–m–M',sub:'$n=m/M$'},
{id:'p7',num:'§ 7',name:'Расчёты для газов',sub:'$V=n\\,V_m$'},
{id:'pr1',num:'ПР 1',name:'Практическая работа',sub:'кол-во вещества'},
{id:'p8',num:'§ 8',name:'Химические реакции',sub:'баланс уравнений'},
{id:'p9',num:'§ 9',name:'Расчёты по уравнениям',sub:'мольные отношения'},
{id:'final1',num:'★',name:'Финал раздела',sub:'босс · +ачивка',final:true}
];
window.ACH_LABELS = {
start:'Начало раздела!', p1_done:'§1 изучен!', p2_done:'§2 изучен!', p3_done:'§3 изучен!',
p4_done:'§4 изучен!', p5_done:'§5 изучен!', p6_done:'§6 изучен!', p7_done:'§7 изучен!',
p8_done:'§8 изучен!', p9_done:'§9 изучен!', pr1_done:'Практическая работа выполнена!',
final1_tasks:'Счёт в химии — раздел пройден!'
};
window.SIDEBARS = {
p1:{title:'Шпаргалка §1',rows:[['Атом','химически неделимая частица'],['Элемент','вид атомов с одинаковым $Z$'],['$A_r$','масса атома / (1/12 масс. $^{12}$C)']]},
p2:{title:'Шпаргалка §2',rows:[['Простое','один элемент ($O_2$)'],['Сложное','разные ($H_2O$)'],['$M_r$','$=\\sum A_r$']]},
p3:{title:'Шпаргалка §3',rows:[['$n$','химическое количество'],['Единица','моль'],['Смысл','«порция» частиц']]},
p4:{title:'Шпаргалка §4',rows:[['$N_A$','$6{,}02\\cdot10^{23}$ 1/моль'],['$N=n\\,N_A$',''],['$n=N/N_A$','']]},
p5:{title:'Шпаргалка §5',rows:[['$M$','г/моль, $=M_r$'],['$V_m$','22,4 л/моль (н.у.)'],['Авогадро','равные $V$ → равные $n$']]},
p6:{title:'Шпаргалка §6',rows:[['$n=m/M$',''],['$m=n\\,M$',''],['$M=m/n$','']]},
p7:{title:'Шпаргалка §7',rows:[['$n=V/V_m$',''],['$V=n\\,V_m$',''],['Связка','$m$$n$$V$$N$']]},
pr1:{title:'Практическая 1',rows:[['Цель','найти $n$ и $N$ по $m$'],['$n=m/M$',''],['$N=n\\,N_A$','']]},
p8:{title:'Шпаргалка §8',rows:[['Закон','сохранение массы'],['Баланс','атомы слева = справа'],['Типы','соед./разл./замещ./обмен']]},
p9:{title:'Шпаргалка §9',rows:[['Алгоритм','$m\\to n\\to n\\to m$'],['Отношение','по коэффициентам'],['Газ','через $V_m$']]},
final1:{title:'Финал раздела',rows:[['§§19','все расчёты'],['Награда','ачивка + XP']]}
};
window.TIPS = [
{sec:'p1',html:'$A_r$ показывает, во сколько раз атом тяжелее $1/12$ атома углерода-12. $A_r(\\text{H})=1$, $A_r(\\text{O})=16$.'},
{sec:'p2',html:'$M_r$ — сумма $A_r$ всех атомов формулы. $M_r(\\text{H}_2\\text{O})=2\\cdot1+16=18$.'},
{sec:'p3',html:'Моль — «порция» вещества. 1 моль любого вещества содержит одинаковое число частиц.'},
{sec:'p4',html:'$N=n\\cdot N_A$. В 1 моль — $6{,}02\\cdot10^{23}$ частиц.'},
{sec:'p5',html:'$M$ численно равна $M_r$. Для газа при н.у. $V_m=22{,}4$ л/моль.'},
{sec:'p6',html:'Закрой искомую величину в треугольнике — получишь формулу: $n=m/M$, $m=nM$, $M=m/n$.'},
{sec:'p7',html:'$n=V/V_m$ (газ, н.у.). Зная одно из $m,n,V,N$ — найдёшь остальные.'},
{sec:'pr1',html:'Взвесь вещество, найди $M$, вычисли $n=m/M$ и $N=n\\,N_A$.'},
{sec:'p8',html:'Атомы в реакции не исчезают: уравнивай коэффициентами, не меняя индексы в формулах.'},
{sec:'p9',html:'Сначала найди $n$ известного, по коэффициентам — $n$ искомого, затем переведи в $m$ или $V$.'},
{sec:'final1',html:'Собери всё: $M_r$, $n=m/M$, $V=n\\,V_m$, $N=n\\,N_A$, расчёты по уравнению.'}
];
/* ── задачи (тренажёр) ── */
window.POOLS = {
p1:[
{q:'Что показывает относительная атомная масса $A_r$?',opts:['Массу атома в граммах','Во сколько раз масса атома больше $1/12$ массы атома $^{12}$C','Число протонов в ядре','Число электронов'],a:1,ex:'$A_r$ — безразмерная величина сравнения с $1/12$ массы атома углерода-12.'},
{q:'$A_r(\\text{S})=32$, $A_r(\\text{O})=16$. Во сколько раз атом серы тяжелее атома кислорода?',hint:'$32/16$',unit:'раза',a:2,ex:'$32/16=2$.'},
{q:'Сколько протонов в ядре атома кальция (порядковый номер $Z=20$)?',hint:'число протонов $=Z$',unit:'',a:20,ex:'Заряд ядра равен порядковому номеру: 20 протонов.'},
{q:'Химический элемент — это…',opts:['Любая мельчайшая частица','Вид атомов с одинаковым зарядом ядра','Молекула вещества','Смесь атомов'],a:1,ex:'Элемент определяется зарядом ядра (числом протонов).'}
],
p2:[
{q:'Чему равна $M_r(\\text{H}_2\\text{O})$?',hint:'$2\\cdot1+16$',unit:'',a:18,ex:'$M_r=2\\cdot1+16=18$.'},
{q:'Чему равна $M_r(\\text{CO}_2)$?',hint:'$12+2\\cdot16$',unit:'',a:44,ex:'$12+32=44$.'},
{q:'$\\text{O}_2$ — это…',opts:['Сложное вещество','Простое вещество','Смесь','Раствор'],a:1,ex:'Образовано атомами одного элемента — простое вещество.'},
{q:'Чему равна $M_r(\\text{H}_2\\text{SO}_4)$?',hint:'$2+32+4\\cdot16$',unit:'',a:98,ex:'$2+32+64=98$.'},
{q:'Чему равна $M_r(\\text{CaCO}_3)$?',hint:'$40+12+3\\cdot16$',unit:'',a:100,ex:'$40+12+48=100$.'}
],
p3:[
{q:'Единица химического количества вещества — это…',opts:['Грамм','Литр','Моль','Паскаль'],a:2,ex:'Химическое количество измеряют в молях.'},
{q:'1 моль любого вещества содержит…',opts:['1 грамм вещества','Одинаковое число частиц','1 литр газа','Разное число частиц'],a:1,ex:'Это и есть смысл моля — фиксированное число частиц.'}
],
p4:[
{q:'Постоянная Авогадро $N_A$ примерно равна…',opts:['$3{,}01\\cdot10^{23}$','$6{,}02\\cdot10^{23}$','$9{,}8$','$22{,}4$'],a:1,ex:'$N_A=6{,}02\\cdot10^{23}$ частиц/моль.'},
{q:'Сколько молекул в $2$ моль воды? Ответ — коэффициент при $\\cdot10^{23}$.',hint:'$N=n\\,N_A=2\\cdot6{,}02$',unit:'$\\cdot10^{23}$',a:12.04,ex:'$2\\cdot6{,}02=12{,}04$ (·10²³).'},
{q:'Чему равно $n$, если число молекул $N=3{,}01\\cdot10^{23}$?',hint:'$n=N/N_A$',unit:'моль',a:0.5,ex:'$3{,}01/6{,}02=0{,}5$ моль.'}
],
p5:[
{q:'Чему равна молярная масса $M(\\text{CO}_2)$?',hint:'численно $=M_r$',unit:'г/моль',a:44,ex:'$M=M_r=44$ г/моль.'},
{q:'Какой объём занимают $2$ моль любого газа при н.у.?',hint:'$V=n\\,V_m$',unit:'л',a:44.8,ex:'$2\\cdot22{,}4=44{,}8$ л.'},
{q:'Чему равно $n$ газа, если его объём при н.у. $V=11{,}2$ л?',hint:'$n=V/V_m$',unit:'моль',a:0.5,ex:'$11{,}2/22{,}4=0{,}5$ моль.'}
],
p6:[
{q:'Найди $n$ воды, если $m=36$ г, $M=18$ г/моль.',hint:'$n=m/M$',unit:'моль',a:2,ex:'$36/18=2$ моль.'},
{q:'Найди массу $0{,}5$ моль $\\text{NaOH}$ ($M=40$ г/моль).',hint:'$m=n\\,M$',unit:'г',a:20,ex:'$0{,}5\\cdot40=20$ г.'},
{q:'Найди $M$ вещества, если $m=44$ г при $n=1$ моль.',hint:'$M=m/n$',unit:'г/моль',a:44,ex:'$44/1=44$ г/моль.'},
{q:'Найди $n$ карбоната кальция, если $m=50$ г, $M=100$ г/моль.',hint:'$n=m/M$',unit:'моль',a:0.5,ex:'$50/100=0{,}5$ моль.'}
],
p7:[
{q:'Какой объём (н.у.) занимают $3$ моль кислорода?',hint:'$V=n\\,V_m$',unit:'л',a:67.2,ex:'$3\\cdot22{,}4=67{,}2$ л.'},
{q:'Найди $n$ газа, если $V=44{,}8$ л при н.у.',hint:'$n=V/V_m$',unit:'моль',a:2,ex:'$44{,}8/22{,}4=2$ моль.'},
{q:'Кислорода взяли $8$ г ($M=32$). Какой объём (н.у.) он займёт?',hint:'$n=m/M$, затем $V=n\\,V_m$',unit:'л',a:5.6,ex:'$n=8/32=0{,}25$; $V=0{,}25\\cdot22{,}4=5{,}6$ л.'}
],
p8:[
{q:'Закон сохранения массы означает, что…',opts:['Масса всегда растёт','Масса реагентов равна массе продуктов','Атомы исчезают','Масса уменьшается'],a:1,ex:'Атомы не исчезают и не появляются — масса сохраняется.'},
{q:'Реакция $\\text{CaCO}_3\\to\\text{CaO}+\\text{CO}_2$ относится к типу…',opts:['Соединения','Разложения','Замещения','Обмена'],a:1,ex:'Из одного вещества — несколько: разложение.'},
{q:'Реакция вида $A+B\\to AB$ — это реакция…',opts:['Разложения','Соединения','Обмена','Замещения'],a:1,ex:'Из нескольких веществ — одно: соединение.'},
{q:'В уравнении $2\\text{H}_2+\\text{O}_2=2\\text{H}_2\\text{O}$ коэффициент перед $\\text{H}_2\\text{O}$ равен…',hint:'смотри на воду',unit:'',a:2,ex:'Коэффициент 2.'}
],
p9:[
{q:'$2\\text{H}_2+\\text{O}_2=2\\text{H}_2\\text{O}$. Дано $m(\\text{H}_2)=4$ г ($M=2$). Найди $m(\\text{H}_2\\text{O})$ ($M=18$).',hint:'$n(\\text{H}_2)=4/2=2$; $n(\\text{H}_2\\text{O})=2$; $m=2\\cdot18$',unit:'г',a:36,ex:'$m=2\\cdot18=36$ г.'},
{q:'$\\text{CaCO}_3=\\text{CaO}+\\text{CO}_2$. Дано $m(\\text{CaCO}_3)=100$ г ($M=100$). Найди $V(\\text{CO}_2)$ при н.у.',hint:'$n=1$; $V=n\\,V_m$',unit:'л',a:22.4,ex:'$n=1$ моль → $V=22{,}4$ л.'},
{q:'$\\text{Zn}+2\\text{HCl}=\\text{ZnCl}_2+\\text{H}_2$. Дано $n(\\text{Zn})=0{,}5$ моль. Найди $V(\\text{H}_2)$ при н.у.',hint:'$n(\\text{H}_2)=0{,}5$; $V=n\\,V_m$',unit:'л',a:11.2,ex:'$0{,}5\\cdot22{,}4=11{,}2$ л.'}
],
final1:[
{q:'$M_r(\\text{H}_2\\text{SO}_4)=?$',hint:'$2+32+64$',unit:'',a:98,ex:'$98$.'},
{q:'Сколько моль в $80$ г $\\text{NaOH}$ ($M=40$)?',hint:'$n=m/M$',unit:'моль',a:2,ex:'$80/40=2$ моль.'},
{q:'Объём (н.у.) $3$ моль $\\text{O}_2$?',hint:'$V=n\\,V_m$',unit:'л',a:67.2,ex:'$67{,}2$ л.'},
{q:'Число молекул в $2$ моль воды (коэф. при $\\cdot10^{23}$)?',hint:'$2\\cdot6{,}02$',unit:'$\\cdot10^{23}$',a:12.04,ex:'$12{,}04$.'},
{q:'$M_r(\\text{Fe}_2\\text{O}_3)=?$',hint:'$2\\cdot56+3\\cdot16$',unit:'',a:160,ex:'$112+48=160$.'},
{q:'$\\text{S}+\\text{O}_2=\\text{SO}_2$. Дано $n(\\text{S})=2$ моль. Найди $V(\\text{SO}_2)$ при н.у.',hint:'$n(\\text{SO}_2)=2$; $V=n\\,V_m$',unit:'л',a:44.8,ex:'$2\\cdot22{,}4=44{,}8$ л.'},
{q:'Какой объём (л, н.у.) занимают 0,5 моль CO₂?',hint:'V=n·22,4',unit:'л',a:11.2,tol:0.05,step:'0.1',ex:'0,5·22,4=11,2 л.'},
{q:'Mr(Na₂CO₃)?',hint:'2·23+12+3·16',unit:'',a:106,ex:'46+12+48=106.'} /*U5-extra*/
]
};
/* ── вспомогательные конструкторы контента ── */
function rememberBox(items){
return '<div class="remember-box"><div class="remember-box-title">'
+'<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Запомни!</div><ul>'
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ul></div>';
}
function qList(items){
return '<div class="section-title">Вопросы и задания</div><ol class="q-list">'
+items.map(function(t){return '<li>'+t+'</li>';}).join('')+'</ol>';
}
function wgt(title, inner){
return '<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg> '+title+'</div>'+inner+'</div>';
}
/* ── BUILDERS параграфов ── */
window.BUILDERS = {
p1:build_p1,p2:build_p2,p3:build_p3,p4:build_p4,p5:build_p5,p6:build_p6,p7:build_p7,
pr1:build_pr1,p8:build_p8,p9:build_p9,final1:build_final1
};
function build_p1(){
document.getElementById('p1-body').innerHTML =
'<div class="para-hero ph-1"><div class="ph-label">§ 1 · Химия 8 кл</div><h2>Атомы. Химические элементы. Относительная атомная масса</h2>'
+'<div class="ph-formula">$A_r(\\text{O}) = 16$</div>'
+'<div class="ph-desc">Из чего состоят вещества и как сравнивают массы атомов, которые невозможно взвесить по отдельности.</div>'
+'<div class="ph-tags"><span class="ph-tag">атом</span><span class="ph-tag">элемент</span><span class="ph-tag">$A_r$</span></div></div>'
+makeCard('theory','Атом и химический элемент','§1','<p><b>Атом</b> — мельчайшая химически неделимая частица вещества. <b>Химический элемент</b> — вид атомов с одинаковым зарядом ядра. Каждый элемент имеет символ ($\\text{H}$, $\\text{O}$, $\\text{Fe}$) и порядковый номер $Z$, равный числу протонов в ядре.</p>'
+'<div class="def-box"><b>Относительная атомная масса</b> $A_r$ показывает, во сколько раз масса атома больше $\\tfrac{1}{12}$ массы атома углерода-12. Это безразмерная величина: $A_r(\\text{H})=1$, $A_r(\\text{O})=16$, $A_r(\\text{Fe})=56$.</div>')
+makeCard('example','Сравнение масс атомов',null,'<p>Во сколько раз атом кислорода тяжелее атома водорода?</p><div class="exa-step">$\\dfrac{A_r(\\text{O})}{A_r(\\text{H})}=\\dfrac{16}{1}=16$ раз.</div>')
+wgt('Карта элементов: клик → $Z$, название, $A_r$','<div class="el-grid" id="p1-el"></div><div class="el-info" id="p1-elinfo">Выберите элемент, чтобы увидеть его характеристики.</div>')
+rememberBox(['Атом нейтрален: число протонов = числу электронов.','$A_r$ — безразмерная, берётся из периодической таблицы.','Заряд ядра (число протонов) = порядковый номер $Z$.'])
+qList(['Чем отличается атом от химического элемента?','Что показывает $A_r$ и почему она безразмерна?','Атом магния имеет $A_r=24$. Во сколько раз он тяжелее атома углерода?'])
+secNav(null,'p2')+readButton('p1');
wireReadBtn('p1');
}
function build_p2(){
document.getElementById('p2-body').innerHTML =
'<div class="para-hero ph-2"><div class="ph-label">§ 2 · Химия 8 кл</div><h2>Молекулы. Простые и сложные вещества. Формулы. $M_r$</h2>'
+'<div class="ph-formula">$M_r=\\sum A_r$</div>'
+'<div class="ph-desc">Как записывают состав вещества формулой и как по ней рассчитывают относительную молекулярную массу.</div>'
+'<div class="ph-tags"><span class="ph-tag">формула</span><span class="ph-tag">индекс</span><span class="ph-tag">$M_r$</span></div></div>'
+makeCard('theory','Вещества и формулы','§2','<p><b>Простое вещество</b> образовано атомами одного элемента ($\\text{O}_2$, $\\text{Fe}$), <b>сложное</b> — разных ($\\text{H}_2\\text{O}$, $\\text{CaCO}_3$). <b>Химическая формула</b> показывает качественный (какие элементы) и количественный (сколько атомов) состав. Нижний индекс — число атомов элемента.</p>'
+'<div class="def-box"><b>Относительная молекулярная масса</b> $M_r$ равна сумме относительных атомных масс всех атомов формулы: $M_r(\\text{H}_2\\text{O})=2\\cdot1+16=18$.</div>')
+makeCard('example','Расчёт $M_r$',null,'<p>$M_r(\\text{H}_2\\text{SO}_4)$:</p><div class="exa-step">$2\\cdot A_r(\\text{H})+A_r(\\text{S})+4\\cdot A_r(\\text{O}) = 2\\cdot1+32+4\\cdot16 = 98$.</div>')
+wgt('Калькулятор $M_r$ по формуле','<div class="fld"><label>Формула</label><input type="text" id="p2-mr-in" value="CaCO3" style="width:160px;font-family:var(--mono)"><button class="btn primary" id="p2-mr-go">Вычислить</button></div>'
+'<div class="fld" style="gap:6px"><button class="btn p2-ex" data-f="H2O">H₂O</button><button class="btn p2-ex" data-f="H2SO4">H₂SO₄</button><button class="btn p2-ex" data-f="Ca(OH)2">Ca(OH)₂</button><button class="btn p2-ex" data-f="Al2(SO4)3">Al₂(SO₄)₃</button></div>'
+'<div class="out" id="p2-mr-out">Введите формулу и нажмите «Вычислить».</div>')
+rememberBox(['Индекс относится только к стоящему перед ним атому/группе.','Скобки: индекс умножает всё внутри — $\\text{Ca(OH)}_2$ = 1 Ca, 2 O, 2 H.','$M_r$ численно совпадает с молярной массой $M$ (§5).'])
+qList(['Чем простое вещество отличается от сложного?','Вычисли $M_r(\\text{Na}_2\\text{CO}_3)$.','Сколько всего атомов в формуле $\\text{Al}_2(\\text{SO}_4)_3$?'])
+secNav('p1','p3')+readButton('p2');
wireReadBtn('p2');
}
function build_p3(){
document.getElementById('p3-body').innerHTML =
'<div class="para-hero ph-3"><div class="ph-label">§ 3 · Химия 8 кл</div><h2>Химическое количество вещества</h2>'
+'<div class="ph-formula">$n$, моль</div>'
+'<div class="ph-desc">Почему химики считают вещество «порциями», а не отдельными атомами.</div>'
+'<div class="ph-tags"><span class="ph-tag">$n$</span><span class="ph-tag">моль</span><span class="ph-tag">порция</span></div></div>'
+makeCard('theory','Зачем нужна «порция»','§3','<p>Атомы и молекулы невозможно считать поштучно — их слишком много даже в крупинке вещества. Поэтому ввели специальную «порцию» — <b>химическое количество вещества</b> $n$, измеряемое в <b>молях</b>.</p>'
+'<div class="def-box">Одна и та же порция — $1$ моль — любого вещества содержит <b>одинаковое число частиц</b>. Химическое количество $n$ связывает массу $m$, число частиц $N$ и объём газа $V$ — это «мост» между атомами и граммами на весах.</div>')
+wgt('Порция вещества: $n \\Rightarrow N$ и $m$','<div class="fld"><label>Вещество</label><select id="p3-sub"><option value="H2O">вода H₂O (M=18)</option><option value="O2">кислород O₂ (M=32)</option><option value="CO2">углекислый газ CO₂ (M=44)</option><option value="NaCl">соль NaCl (M=58,5)</option></select><label>n, моль</label><input type="range" id="p3-n" min="0.1" max="5" step="0.1" value="1"><span class="bd" id="p3-nv">1,0</span></div><div class="out" id="p3-out"></div>')
+rememberBox(['$n$ измеряют в молях.','1 моль ≠ 1 грамм: масса 1 моль зависит от вещества (§5).','Чем больше $n$, тем больше и масса, и число частиц.'])
+qList(['Почему вещество удобнее измерять в молях, а не поштучно?','Что общего у 1 моль воды и 1 моль железа?'])
+secNav('p2','p4')+readButton('p3');
wireReadBtn('p3');
}
function build_p4(){
document.getElementById('p4-body').innerHTML =
'<div class="para-hero ph-4"><div class="ph-label">§ 4 · Химия 8 кл</div><h2>Моль. Постоянная Авогадро</h2>'
+'<div class="ph-formula">$N = n\\cdot N_A$</div>'
+'<div class="ph-desc">Сколько именно частиц содержит одна порция-моль и как считать их число.</div>'
+'<div class="ph-tags"><span class="ph-tag">$N_A$</span><span class="ph-tag">$6{,}02\\cdot10^{23}$</span></div></div>'
+makeCard('theory','Постоянная Авогадро','§4','<div class="def-box"><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>')
+makeCard('example','Сколько частиц?',null,'<p>Сколько молекул в $0{,}5$ моль воды?</p><div class="exa-step">$N=n\\,N_A=0{,}5\\cdot6{,}02\\cdot10^{23}=3{,}01\\cdot10^{23}$ молекул.</div>')
+wgt('Счётчик частиц $N = n\\cdot N_A$','<div class="fld"><label>n, моль</label><input type="range" id="p4-n" min="0.25" max="10" step="0.25" value="2"><span class="bd" id="p4-nv">2,0</span></div><div class="out" id="p4-out"></div>')
+rememberBox(['$N_A=6{,}02\\cdot10^{23}$ — огромное число: столько частиц в одной порции-моль.','$N=n\\,N_A$ — частиц; $n=N/N_A$ — молей.'])
+qList(['Сколько атомов в 2 моль железа?','Сколько моль составляют $1{,}204\\cdot10^{24}$ молекул?'])
+secNav('p3','p5')+readButton('p4');
wireReadBtn('p4');
}
function build_p5(){
document.getElementById('p5-body').innerHTML =
'<div class="para-hero ph-5"><div class="ph-label">§ 5 · Химия 8 кл</div><h2>Молярная масса. Молярный объём газов</h2>'
+'<div class="ph-formula">$V_m=22{,}4$ л/моль</div>'
+'<div class="ph-desc">Масса и объём одной порции-моль: для любого вещества и для любого газа.</div>'
+'<div class="ph-tags"><span class="ph-tag">$M$</span><span class="ph-tag">$V_m$</span><span class="ph-tag">н.у.</span></div></div>'
+makeCard('theory','M и Vm','§5','<div class="def-box"><b>Молярная масса</b> $M$ — масса $1$ моль вещества, г/моль. Численно $M$ равна $M_r$: $M(\\text{H}_2\\text{O})=18$ г/моль.</div>'
+'<div class="def-box"><b>Молярный объём</b> $V_m$ — объём $1$ моль газа. При нормальных условиях (н.у., $0\\,^\\circ$C и $101{,}3$ кПа) $V_m = 22{,}4$ л/моль для <b>любого</b> газа (следствие закона Авогадро).</div>')
+wgt('M по формуле и объём 1 моль газа','<div class="fld"><label>Формула газа</label><input type="text" id="p5-in" value="CO2" style="width:140px;font-family:var(--mono)"><button class="btn primary" id="p5-go">Найти M</button></div><div class="out" id="p5-out">M(CO₂) и объём при н.у. появятся здесь.</div>')
+'<div class="insight-box"><div class="insight-title"><svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> Закон Авогадро</div><p>В равных объёмах разных газов при одинаковых условиях — <b>равное число молекул</b>. Поэтому 1 моль любого газа занимает одинаковый объём 22,4 л.</p></div>'
+rememberBox(['$M$ (г/моль) численно равна $M_r$.','$V_m=22{,}4$ л/моль — только для газов и только при н.у.','У жидкостей и твёрдых веществ молярного объёма 22,4 л нет.'])
+qList(['Чему равна $M(\\text{NaOH})$?','Какой объём при н.у. займут 0,5 моль любого газа?'])
+secNav('p4','p6')+readButton('p5');
wireReadBtn('p5');
}
function build_p6(){
document.getElementById('p6-body').innerHTML =
'<div class="para-hero ph-6"><div class="ph-label">§ 6 · звёздный виджет</div><h2>Вычисление $n$ по массе и массы по $n$</h2>'
+'<div class="ph-formula">$n = \\dfrac{m}{M}$</div>'
+'<div class="ph-desc">Три связанные величины и удобный «треугольник» для запоминания формул.</div>'
+'<div class="ph-tags"><span class="ph-tag">$n=m/M$</span><span class="ph-tag">$m=nM$</span><span class="ph-tag">$M=m/n$</span></div></div>'
+makeCard('rule','Треугольник n–m–M','§6','<p>Все три величины связаны формулой $m = n\\cdot M$. Закрой искомую в треугольнике — получишь нужную формулу: $n=\\dfrac{m}{M}$, $m=n\\cdot M$, $M=\\dfrac{m}{n}$.</p>')
+makeCard('example','Разбор',null,'<p>Дано: $m=36$ г воды, $M=18$ г/моль. Найти $n$.</p><div class="exa-step">$n = \\dfrac{m}{M} = \\dfrac{36}{18} = 2$ моль.</div>')
+'<div class="flag-card"><div class="flag-title">Интерактивный треугольник n–mM</div><div class="flag-help">Введи любые два значения — третье вычислится. Можно подставить $M$ вещества.</div>'
+'<div class="fld"><label>Подставить M вещества</label><select id="p6-sub"><option value="">— вручную —</option><option value="H2O">H₂O · 18</option><option value="CO2">CO₂ · 44</option><option value="NaOH">NaOH · 40</option><option value="CaCO3">CaCO₃ · 100</option><option value="H2SO4">H₂SO₄ · 98</option></select></div><div id="p6-mount"></div></div>'
+rememberBox(['$m=n\\cdot M$ — основная формула, остальные выводятся из неё.','Единицы: $n$ — моль, $m$ — г, $M$ — г/моль.','Проверяй размерность: г / (г/моль) = моль.'])
+qList(['Найди массу 2 моль $\\text{CO}_2$.','Сколько моль в 40 г $\\text{NaOH}$?'])
+secNav('p5','p7')+readButton('p6');
wireReadBtn('p6');
}
function build_p7(){
document.getElementById('p7-body').innerHTML =
'<div class="para-hero ph-7"><div class="ph-label">§ 7 · Химия 8 кл</div><h2>Вычисление количества и объёма газа</h2>'
+'<div class="ph-formula">$n = \\dfrac{V}{V_m}$</div>'
+'<div class="ph-desc">Объёмы газов — через молярный объём. Единая связка четырёх величин.</div>'
+'<div class="ph-tags"><span class="ph-tag">$V=n\\,V_m$</span><span class="ph-tag">mnVN</span></div></div>'
+makeCard('rule','Связка m n V N','§7','<p>Для газа при н.у.: $n=\\dfrac{V}{V_m}$ и $V=n\\cdot V_m$, где $V_m=22{,}4$ л/моль. Вместе с $n=\\dfrac{m}{M}$ и $N=n\\cdot N_A$ это единая система: зная любую величину, найдёшь все остальные через $n$.</p>'
+'<div class="def-box">$m \\xrightarrow{\\;:M\\;} n \\xrightarrow{\\;\\cdot V_m\\;} V$, и $n \\xrightarrow{\\;\\cdot N_A\\;} N$. Центр всей системы — количество вещества $n$.</div>')
+makeCard('example','Через массу к объёму',null,'<p>$8$ г кислорода ($M=32$). Какой объём он займёт при н.у.?</p><div class="exa-step">$n=\\dfrac{8}{32}=0{,}25$ моль → $V=0{,}25\\cdot22{,}4=5{,}6$ л.</div>')
+'<div class="flag-card"><div class="flag-title">Универсальный калькулятор газа</div><div class="flag-help">Выбери газ, укажи известную величину — получишь $n$, $m$, $V$ и $N$.</div>'
+'<div class="fld"><label>Газ</label><select id="p7-sub"><option value="O2">O₂ · M=32</option><option value="CO2">CO₂ · M=44</option><option value="H2">H₂ · M=2</option><option value="N2">N₂ · M=28</option></select></div>'
+'<div class="fld"><label>Известно</label><select id="p7-key"><option value="n">n, моль</option><option value="m">m, г</option><option value="V">V, л (н.у.)</option><option value="N">N, ·10²³</option></select><input type="text" id="p7-val" value="1" style="width:110px;font-family:var(--mono)"><button class="btn primary" id="p7-go">Рассчитать</button></div><div class="out" id="p7-out"></div></div>'
+rememberBox(['$V_m=22{,}4$ л/моль — только при н.у.','Всегда сначала находи $n$, потом переходи к нужной величине.'])
+qList(['Какой объём займут 3 моль водорода при н.у.?','Сколько моль газа в 44,8 л при н.у.?'])
+secNav('p6','pr1')+readButton('p7');
wireReadBtn('p7');
}
function build_pr1(){
document.getElementById('pr1-body').innerHTML =
'<div class="para-hero ph-pr"><div class="ph-label">Практическая работа 1</div><h2>Химическое количество вещества</h2>'
+'<div class="ph-formula">$n=\\dfrac{m}{M}$, $N=n\\,N_A$</div>'
+'<div class="ph-desc">Связать измеримую массу с числом частиц через химическое количество.</div></div>'
+makeCard('lab','Порядок работы',null,'<ol><li>Взвесь на весах образцы веществ (например, $\\text{NaCl}$, $\\text{CuSO}_4$).</li><li>Запиши массу $m$ и определи молярную массу $M$ по формуле.</li><li>Вычисли химическое количество $n=\\dfrac{m}{M}$.</li><li>Найди число частиц $N=n\\cdot N_A$.</li><li>Оформи вывод: какому числу частиц соответствует взятая масса.</li></ol>'
+'<div class="note-safe"><svg 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>')
+wgt('Тренажёр расчёта (как в работе)','<div class="fld"><label>Подставить M</label><select id="pr1-sub"><option value="">— вручную —</option><option value="NaCl">NaCl · 58,5</option><option value="CuSO4">CuSO₄ · 160</option><option value="H2O">H₂O · 18</option></select></div><div id="pr1-mount"></div>')
+secNav('p7','p8')+readButton('pr1');
wireReadBtn('pr1');
}
function build_p8(){
document.getElementById('p8-body').innerHTML =
'<div class="para-hero ph-8"><div class="ph-label">§ 8 · звёздный виджет</div><h2>Химические реакции</h2>'
+'<div class="ph-formula">закон сохранения массы</div>'
+'<div class="ph-desc">Что происходит с атомами в реакции и как уравнивают химические уравнения.</div>'
+'<div class="ph-tags"><span class="ph-tag">баланс</span><span class="ph-tag">коэффициенты</span><span class="ph-tag">типы</span></div></div>'
+makeCard('theory','Уравнение реакции','§8','<p>В химической реакции одни вещества превращаются в другие, но <b>атомы не исчезают и не появляются</b> — это <b>закон сохранения массы</b> (М. В. Ломоносов, А. Лавуазье). Поэтому уравнение реакции <b>уравнивают коэффициентами</b>: число атомов каждого элемента слева и справа должно быть равным. Менять индексы внутри формул нельзя!</p>')
+makeCard('example','Типы реакций',null,'<ul><li><b>Соединения:</b> $A+B\\to AB$ — из нескольких веществ одно.</li><li><b>Разложения:</b> $AB\\to A+B$ — из одного несколько.</li><li><b>Замещения:</b> $A+BC\\to AC+B$.</li><li><b>Обмена:</b> $AB+CD\\to AD+CB$.</li></ul>')
+'<div class="flag-card"><div class="flag-title">Балансировщик: расставь коэффициенты</div><div class="flag-help">Подбери коэффициенты так, чтобы число атомов каждого элемента совпало слева и справа.</div>'
+'<div class="fld" style="gap:6px"><label>Реакция</label><select id="p8-pick"><option value="H2 + O2 -> H2O|2,1,2">H₂ + O₂ → H₂O</option><option value="Fe + O2 -> Fe2O3|4,3,2">Fe + O₂ → Fe₂O₃</option><option value="Al + HCl -> AlCl3 + H2|2,6,2,3">Al + HCl → AlCl₃ + H₂</option><option value="CH4 + O2 -> CO2 + H2O|1,2,1,2">CH₄ + O₂ → CO₂ + H₂O</option></select></div><div id="p8-mount"></div></div>'
+rememberBox(['Уравнивают только коэффициентами, индексы не трогают.','Коэффициент относится ко всей формуле сразу.','Сумма масс реагентов = сумме масс продуктов.'])
+qList(['Сформулируй закон сохранения массы.','К какому типу относится реакция $\\text{Zn}+\\text{CuSO}_4\\to\\text{ZnSO}_4+\\text{Cu}$?'])
+secNav('pr1','p9')+readButton('p8');
wireReadBtn('p8');
}
function build_p9(){
document.getElementById('p9-body').innerHTML =
'<div class="para-hero ph-9"><div class="ph-label">§ 9 · звёздный виджет</div><h2>Количественные расчёты по уравнениям реакций</h2>'
+'<div class="ph-formula">по мольным отношениям</div>'
+'<div class="ph-desc">Главный приём химии: по массе одного вещества рассчитать массу или объём другого.</div>'
+'<div class="ph-tags"><span class="ph-tag">$n$</span><span class="ph-tag">коэффициенты</span><span class="ph-tag">$m$, $V$</span></div></div>'
+makeCard('rule','Алгоритм расчёта','§9','<ol><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></ol>')
+'<div class="flag-card"><div class="flag-title">Пошаговый решатель по уравнению</div><div class="flag-help">Выбери задачу и раскрывай решение по шагам.</div>'
+'<div class="fld"><label>Задача</label><select id="p9-pick"></select></div><div class="out" id="p9-out"></div>'
+'<div class="fld"><button class="btn" id="p9-step">Следующий шаг ▸</button><button class="btn" id="p9-all">Показать всё решение</button></div></div>'
+rememberBox(['Мольное отношение берут из коэффициентов уравнения.','Уравнение обязательно уравнивают до расчёта.','Для газов используют $V=n\\,V_m$ вместо $m=n\\,M$.'])
+qList(['$\\text{CaCO}_3=\\text{CaO}+\\text{CO}_2$. Сколько $\\text{CO}_2$ (л, н.у.) даст 200 г $\\text{CaCO}_3$?','$2\\text{Mg}+\\text{O}_2=2\\text{MgO}$. Сколько MgO даст 2 моль Mg?'])
+secNav('p8','final1')+readButton('p9');
wireReadBtn('p9');
}
function build_final1(){
document.getElementById('final1-body').innerHTML =
'<div class="para-hero ph-final"><div class="ph-label">Финал раздела</div><h2>Босс: количественные понятия</h2>'
+'<div class="ph-formula">$M_r$ · $n=m/M$ · $V=n\\,V_m$ · $N=n\\,N_A$</div>'
+'<div class="ph-desc">Шесть интегрированных задач на всё, что изучено. Реши все — получи ачивку «Счёт в химии».</div></div>'
+makeCard('rule','Шпаргалка раздела',null,'<div class="formula-grid">'
+'<div class="fcard"><h3>Масса ↔ количество</h3><div class="main-f">n = m / M</div></div>'
+'<div class="fcard"><h3>Объём газа</h3><div class="main-f">V = n · 22,4</div></div>'
+'<div class="fcard"><h3>Число частиц</h3><div class="main-f">N = n · 6,02·10²³</div></div>'
+'<div class="fcard highlight"><h3>Связка</h3><div class="main-f">m → n → V, N</div></div></div>')
+'<p style="margin:10px 0;color:var(--muted);font-size:.9rem">Реши задачи ниже — за каждую +5 XP, за полный разгром босса — ачивка и бонус.</p>'
+'<div class="flag-card"><div class="flag-title">Карта связей понятий</div><div class="flag-help">Кликни по связи — увидишь, как понятия главы связаны.</div><div id="c-concept"></div></div>'+secNav('p9',null);
}
/* ── монтаж sidebar после загрузки ачивки (движок сам строит) ── */
</script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
# Feature Context: Контент-движок лаборатории
## Current State
- Лаборатория работает на захардкоженной регистрации (см. PLAN.md Summary).
- Ветка `feature/lab-content-engine` создана от `master`.
## Architecture map (как было ДО рефактора)
- `frontend/lab.html` — sim-тела `<div id="sim-xxx">` (inline HTML, ~3000 строк) + 58 `<script>` тегов (4800-4861) + three.js.
- `frontend/js/labs/lab-glue.js`:
- `_catFilter`, `_disabledSimIds`, `_simModuleDisabled` (вкл/выкл из админки)
- `filterSims()`, `renderSims()` (карточки каталога)
- preview-хелперы `_grid/_axes/_svg` + ~60 констант `P_*`
- массив `SIMS` (821-866), `window.SIMS`/`window.LAB_SIMS`
- `frontend/js/labs/lab-init.js`:
- объявления переменных симуляций (gSim, pSim, …)
- `ALL_SIM_BODIES` / `ALL_CTRL_BARS` (33-48)
- `_pauseAllSims()` (54-91), `openSim(id)` if-цепочка (93-160), `closeSim()` (212-258)
- `_simShow()`, `_addTouchSupport()` (touch-bridge + ResizeObserver)
- объект `THEORY` + `loadTheory()` + `_theoryToggle()`
- функции `_openXxx()` (603-756) — единый шаблон: `_simShow('sim-xxx')` + ленивое `new XxxSim(...)` + показ `ctrl-xxx`
- `frontend/js/admin/sections/sims.js` — админ-секция (пока только вкл/выкл, `_disabledSimIds`).
## Загрузочный порядок (КРИТИЧНО)
В lab.html: движки `_fx_*`, `_phys_visuals`, `_graph_panel`, `_chem_visuals` грузятся ПЕРЕД симуляциями.
`lab-init.js` (4826) грузится ПЕРЕД `lab-glue.js` (4827). `renderSims()` вызывается в конце lab-glue.
Некоторые sim-файлы (graph.js) грузятся РАНЬШЕ lab-glue.js → preview `P_*` ещё не определены на момент исполнения их тел.
=> В манифестах `preview` поддерживает функцию (ленивое вычисление в renderSims), не только строку.
## Контракт LabRegistry (Фаза 0)
```
LabRegistry.register(manifest) // manifest.id уникален; повторная регистрация перезаписывает
LabRegistry.get(id) // по base-id (без ':arg')
LabRegistry.has(id)
LabRegistry.all() // в порядке регистрации
LabRegistry.setActive(sim) / stopActive() / destroyActive() // менеджер жизненного цикла
```
manifest: `{ id, cat, title, desc, preview(string|fn), theory?, bodyId?, mount?(host), open(ctx), stop?(), destroy?(), subject?, grade?, topics? }`
## Адаптер (Фаза 0): реестр в приоритете, иначе legacy
- `renderSims()` — порядок берём из исходного `SIMS`; для id, который есть в реестре, используем манифест (resolve preview), иначе legacy-запись; в конце добавляем registry-only записи, которых нет в SIMS.
- `openSim(id)``base = id.split(':')[0]`; если `LabRegistry.has(base)``stopActive()`; `get(base).open({arg})`; `setActive`; иначе старый if-путь.
- `loadTheory(id)` — если `get(base).theory` есть → рендерим из него; иначе `THEORY[base]`.
- `closeSim()`/`_pauseAllSims()` — дополнительно `LabRegistry.stopActive()` / `destroyActive()`.
## RESUME STATE — Phase 4 done (2026-05-30, latest)
- Ф4: каталог симуляций в БД. Миграция `042_lab_sims.sql` (таблица lab_sims, сид 40), `backend/src/routes/lab.js` (GET /api/lab/sims auth; PATCH /:id + POST /reorder admin), mount в server.js, 11 тестов, переписан admin/sections/sims.js (убран хардкод ADMIN_SIMS).
- enabled зеркалится в legacy app_settings.sim_disabled_ids → lab.html без правок. preview-SVG остаются в коде.
- Ревью PASS (без блокеров). route-auth lint чистый. Миграция применена к живой БД. Запушено, remote синхронен.
- ВАЖНО: `npm test` = 3 PRE-EXISTING baseline-фейла (НЕ мои; документированы с lab-split). pre-commit хук: BASELINE_FAILS=3, блокирует только при >3. Мои 11 проходят, +0.
- ⚠️ Параллельная сессия коммитит в ветку — был 2 behind, rebase прошёл чисто (без пересечений по файлам). Всегда fetch+rebase перед push.
- НЕ ПРОВЕРЕНО В БРАУЗЕРЕ: админка «Симуляции» (грузит /api/lab/sims, тумблеры, звезда featured) + исчезновение выключенной симуляции на /lab.
- ОСТАЛОСЬ: Фаза 5 (курикулум: lab_sim_links + кнопки «Открыть в лаборатории» в учебнике/теории + связанная теория/задачи на странице sim). subject/grade/featured/tags уже в схеме lab_sims.
## RESUME STATE — Phase 3 done + FIXED (2026-05-30, ранее)
- HEAD=9069d80 (Ф3 + критический фикс). ЗАПУШЕНО, remote синхронен (0 0).
- ВАЖНЫЙ УРОК: коммит fc1139f был СЛОМАН — 2 edit'а (_register-all open-обёртка + lab-init Promise-обработка) не применились (упали по отступу old_string), а я запушил, не заметив. Ревью-агент поймал: lab.html убрал eager-скрипты, но open остался синхронным → ReferenceError на клике. Фикс в 9069d80. ПРАВИЛО: после каждого edit проверять `grep -c` маркера; не пушить пакет без поштучной верификации.
- ТЕПЕРЬ КОРРЕКТНО: open → LabLoader.ensure(id).then(rawOpen); openSim обрабатывает Promise. E2E vm-harness (click→ensure→load→rawOpen, pendulum/stereo:cube/molphys/alias magnetic) ALL PASS.
## RESUME STATE — Phase 3 done (исходный, до фикса)
- HEAD=70762be (Ф3). Ленивая загрузка кода: старт /lab ~530KB вместо ~2.9MB+600KB three.js (~6×).
- Новые файлы: `_loader.js`, `_sim_deps.js` (генерированный манифест). Правки: `_register-all.js`, `lab-init.js`, `lab.html` (eager сокращён до каркаса).
- Манифест SIM_DEPS: каждый sim → {open, files[], three}. Инвариант (проверен): файл, определяющий open-функцию, ВСЕГДА в files[] (кроме graph — он eager). Self-heal = страховка.
- ⚠️ PUSH: на момент завершения Ф3 окружение глючило (пустой вывод команд); локально 2 коммита ahead (Ф2-docs + Ф3), 0 behind. НУЖНО допушить: `git push origin feature/lab-content-engine` (мог не пройти из-за транзиентного auth — повторить).
- ⚠️ НЕ ПРОВЕРЕНО В БРАУЗЕРЕ (см. чеклист в phase-3-lazy-load.md handoff).
- ГЕНЕРАТОР МАНИФЕСТА был временным (%TEMP%, удалён). Логика: framework-set + статический анализ provides/refs (комментарии вырезаются) + транзитивное замыкание по ленивым файлам; 3D-set хардкод {crystal,orbitals,stereo,periodic}; EXTRA_DEPS={periodic:[_periodic_data.js]}. При Ф4/5 положить в tools/gen-sim-deps.js.
- СЛЕДУЮЩЕЕ: Ф4 (БД lab_sims + API + админка, backend — не трогает lab.html) или Ф5 (курикулум).
## RESUME STATE — Phase 2 done (2026-05-30, ранее)
- Ф2: 40 тел симуляций (~4420 строк) вынесены из lab.html (4880→484 строк) в `frontend/labs-bodies.html`. На месте региона — `#sim-bodies-host` + инлайн-скрипт с СИНХРОННЫМ XHR (`open(...,false)`), который во время парсинга грузит partial и `insertAdjacentHTML('beforebegin')` вставляет тела ДО хоста, затем удаляет хост. Тела присутствуют до DOMContentLoaded → обработчики geometry.js:3207 и порядок init сохранены.
- ctrl-бары (#ctrl-*) и #theory-panel ОСТАЛИСЬ в lab.html (они в topbar, не в регионе).
- partial раздаётся существующим `express.static(frontendDir)` (server.js:475) — новый роут не нужен.
- ГАРАНТИИ (механические, не браузерные): реконструкция before+region+after == оригинал ПОБАЙТОВО; id-мультимножество (newLabhost)+partial == оригинал; 40 sim-body div; node --check OK.
- ⚠️ НЕ ПРОВЕРЕНО В БРАУЗЕРЕ. РИСКИ к проверке вручную: (1) sync-XHR может блокироваться при file:// — но тут Express, ок; (2) консольное предупреждение о deprecated sync XHR — безвредно; (3) CSP на инлайн-скрипт — на странице уже есть инлайн-скрипты, должно быть ок; (4) кэш partial (?v=1) — при правках бампать версию.
- ПРОВЕРИТЬ: открыть /lab, дождаться каталога, открыть несколько симуляций (graph, pendulum, geometry — у неё DOMContentLoaded-кнопки, stereo:cube, opticsbench), убедиться что canvas рисуется и кнопки работают.
- ОТКАТ: `git revert <commit Ф2>` или вернуть регион из labs-bodies.html обратно.
- СЛЕДУЮЩЕЕ: Фаза 3 (ленивая загрузка кода) ИЛИ Фаза 4 (БД+админка, backend — без конфликтов с lab.html). Параллельная сессия всё ещё может править lab.html.
## RESUME STATE — Phase 1 done (2026-05-30, later)
- Коммиты: 36c091b → 0888a70 (фикс Ф0) → ebb2a9b (Ф1). HEAD=ebb2a9b, ЗАПУШЕНО, remote синхронен.
- Ф1: централизованный `_register-all.js` (data-driven из SIMS+THEORY+OPEN map 40 шт), if-цепочка openSim удалена, _pilots.js удалён, LAB_SIM_ALIASES добавлены. Ревью PASS, vm-harness ALL PASS.
- SIMS/THEORY/_pauseAllSims/closeSim/ALL_SIM_BODIES/ALL_CTRL_BARS НАМЕРЕННО оставлены (источники данных + lifecycle-дробовик) — паритет без браузера. Их удаление = Фаза 2 (с ленивым mount).
- БЛОКЕР-РИСК: параллельная сессия (biochem/opticsbench) активно правит lab.html и откатывала мои include-правки. Перед Фазой 2 (вынос ~3000 строк из lab.html) — СОГЛАСОВАТЬ, иначе конфликты/потеря работы.
- СЛЕДУЮЩЕЕ: пользователю желательно открыть /lab в браузере и кликнуть несколько симуляций (особенно с :arg — стерео-фигуры, оптика-режимы) перед Фазой 2.
## RESUME STATE (2026-05-30, ранее)
- Ветка `feature/lab-content-engine`. Коммиты: 36c091b (Фаза 0, была неполной) → 8f72d68 (фикс 3 блокеров, ЗАПУШЕН).
- Первое ревью Фазы 0 = FAIL (3 блокера: _registry.js не подключён, пилоты не зарегистрированы, loadTheory не адаптирован). ВСЕ ТРИ ИСПРАВЛЕНЫ в 8f72d68:
- `_registry.js` подключён в lab.html:4799 (после three.js).
- `frontend/js/labs/_pilots.js` — регистрирует graph/quadratic/pendulum; подключён в lab.html:4863 (defer, последним).
- `loadTheory` (lab-glue.js:951) — реестр в приоритете.
- СЛЕДУЮЩИЙ ШАГ: повторное независимое ревью Фазы 0 (агент). Если PASS → Фаза 1.
- Фаза 1 решено вести ПО КАТЕГОРИЯМ с коммитами (math→phys→chem→bio→game), legacy удалять в конце.
- НЕ ПРОВЕРЕНО в браузере (нет автоматизации). Паритет — статически + ревью.
- ВАЖНО: _open* функции глобальны (graph.js:506, quadratic.js:454, pendulum.js:1749). P_* — top-level const в lab-glue.js (доступны cross-script). THEORY — const в lab-init.js. Манифест Фазы1 регистрировать в самом sim-файле, но следить за порядком (preview/theory как fn если файл грузится до lab-glue/lab-init).
- УРОК: lab-glue.js/lab-init.js часто перенумеровываются линтером — перед Edit перечитывать. Делать правки ПО ОДНОЙ с проверкой (был сбой пакетного выполнения).
## Temporary Workarounds
- Пилоты (graph/quadratic/pendulum) оставлены в SIMS/THEORY для порядка карточек и единого источника теории; merge перекрывает по id. Удалить в Фазе 1.
## Known follow-ups (из ревью Фазы 0)
- При переключении на LEGACY-симуляцию `LabRegistry._active` не очищается → лишний destroyActive() на неактивной. Безвредно сейчас; очистить `_active` на legacy-open в Фазе 1.
## Cross-Phase Dependencies
- Фаза 1 опирается на ядро реестра из Фазы 0.
- Фаза 3 (ленивая загрузка) опирается на манифесты с зависимостями движков (Фаза 1/2).
- Фаза 4 (БД) мёржит код-манифесты Фазы 1 с оверрайдами.
- Фаза 5 использует поля subject/grade/topics из манифестов.
## Deep-links (сохранить!)
`openSim('stereo:figure')`, `?stereofig=`, обратная совместимость `magnetic/coulomb→emfield`, `thinlens/mirrors/refraction→opticsbench`.
## Проектные правила (НЕ нарушать)
- Иконки: только inline SVG `.ic`, НЕ эмоджи.
- Поиск по коду: ast-index, НЕ Grep tool.
- БД: встроенный `node:sqlite` DatabaseSync, НЕ better-sqlite3.
- Git: коммитить только изменённые файлы.
+53
View File
@@ -0,0 +1,53 @@
# Feature: Контент-движок лаборатории (симуляции как данные)
**Branch:** `feature/lab-content-engine`
**Base branch:** `master`
**Created:** 2026-05-30
**Status:** ✅ Complete (все 6 фаз; backend полностью, фронт Ф5 — параллельная сессия; не мёржено в master)
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Direct
## Summary
Превратить захардкоженную в 6 местах регистрацию ~49 симуляций лаборатории в единый
декларативный манифест + реестр (`LabRegistry`). Каждая симуляция сама себя регистрирует
объектом `{id, cat, title, desc, preview, theory, bodyId/mount, open, stop, destroy, subject, grade, topics}`.
Ядро (renderSims/openSim/closeSim/loadTheory) работает с реестром, а не с массивами и
if-цепочками. Далее — ленивая загрузка кода, БД-бэкенд с админкой и курикулумная привязка.
## Build & Test Commands
- **Build:** — (фронт без сборки, статика через Express)
- **Test:** `cd backend && npm test` (актуально для Фаз 4-5; Фазы 0-3 — статическая проверка + ревью по диффу)
- **Lint:** `cd backend && npm run lint:routes` (актуально для Фаз 4-5)
## Phases
- [x] Phase 0: Ядро реестра + адаптер + 3 пилота [domain: frontend] → [subplan](./phase-0-registry-core.md)
- [x] Phase 1: Миграция всех симуляций на манифесты [domain: frontend] → [subplan](./phase-1-migrate-all.md)
- [x] Phase 2: Тела симуляций вынесены в labs-bodies.html (sync-инъекция) [domain: frontend] → [subplan](./phase-2-lazy-mount.md)
- [x] Phase 3: Ленивая загрузка кода симуляций [domain: frontend] → [subplan](./phase-3-lazy-load.md)
- [x] Phase 4: Реестр в БД + API + админка [domain: fullstack] → [subplan](./phase-4-db-admin.md)
- [x] Phase 5: Курикулумная привязка [domain: fullstack] → [subplan](./phase-5-curriculum.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 0: Ядро реестра | frontend | ✅ Done (fix 0888a70) | ✅ PASS (re-review) | ✅ n/a | ⚠️ local only (push auth fail) |
| Phase 1: Миграция всех | frontend | ✅ Done (ebb2a9b) | ✅ PASS | ✅ n/a | ✅ pushed |
| Phase 2: Вынос тел | frontend | ✅ Done | ✅ браузер-проверка пройдена | ✅ n/a | ✅ pushed |
| Phase 3: Ленивая загрузка | frontend | ✅ Done (201e94e +fix) | ✅ E2E harness ALL PASS | ✅ n/a | ⚠️ нужна браузер-проверка |
| Phase 4: БД + админка | fullstack | ✅ Done | ✅ PASS (review) | ✅ 11/11 +0 baseline | ✅ pushed |
| Phase 5: Курикулум | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [x] Per-phase independent reviews (PASS)
- [x] Backend tests pass (lab-sims 11/11, lab-links 18/18; +0 к baseline)
- [x] route-auth lint: 0 роутов lab.js во флаге
- [ ] Merged to `master` (НЕ выполнено — ветка общая, мёрж за пользователем)
## Notes (Big Bang temporary breakage map)
- Фаза 1 может временно ломать каталог/открытие симуляций пока миграция не завершена — устраняется внутри Фазы 1.
- Фаза 2 временно меняет структуру lab.html (вынос тел) — устраняется внутри Фазы 2.
- Полная работоспособность лаборатории гарантируется после ФИНАЛЬНОЙ фазы.
@@ -0,0 +1,51 @@
# Phase 0: Ядро реестра + адаптер + 3 пилота
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Создать `LabRegistry` (реестр + менеджер активной симуляции). Подключить адаптер: ядро
лаборатории сначала смотрит в реестр, иначе — старый путь. Мигрировать 3 пилота
(graph, quadratic, pendulum) и доказать паритет. Полностью обратимо.
## Tasks
- [ ] Создать `frontend/js/labs/_registry.js``window.LabRegistry` (register/get/has/all + setActive/stopActive/destroyActive). Без эмоджи.
- [ ] Подключить `_registry.js` в lab.html ПЕРВЫМ среди labs-скриптов (до graph.js).
- [ ] Адаптер `renderSims()` (lab-glue.js): порядок из SIMS, registry-override + resolve preview (string|fn), append registry-only.
- [ ] Адаптер `openSim()` (lab-init.js): base-id, registry-first → stopActive/open/setActive; deep-link `:arg` сохранить.
- [ ] Адаптер `loadTheory()` (lab-init.js): registry.theory в приоритете, иначе THEORY[base].
- [ ] Адаптер `closeSim()`/`_pauseAllSims()`: добавить `LabRegistry.stopActive()`/`destroyActive()`.
- [ ] Зарегистрировать 3 пилота в конце lab-init.js (после _openXxx): graph, quadratic, pendulum — с preview-fn, theory-объектом, open=_openXxx, stop/destroy.
- [ ] Удалить graph/quadratic/pendulum из legacy `THEORY` и `SIMS` (проверка, что адаптер их подхватывает из реестра).
## Files to Modify/Create
- `frontend/js/labs/_registry.js` — новый: ядро реестра.
- `frontend/lab.html` — добавить `<script src="/js/labs/_registry.js">` первым (в обоих местах, если дублируется список).
- `frontend/js/labs/lab-glue.js` — renderSims адаптер; убрать 3 пилота из SIMS.
- `frontend/js/labs/lab-init.js` — openSim/loadTheory/closeSim/_pauseAllSims адаптеры; регистрация 3 пилотов; убрать 3 пилота из THEORY.
## Acceptance Criteria
- Каталог отображает все симуляции в прежнем порядке; 3 пилота открываются и работают идентично.
- Остальные 46 симуляций открываются по-старому (legacy путь не сломан).
- Deep-links и обратная совместимость id работают.
- Нет дублей карточек (пилот не показан дважды).
- Нет эмоджи; иконки `.ic`.
## Notes
- Порядок загрузки: см. CONTEXT.md. preview как функция спасает от undefined P_*.
- `_disabledSimIds` фильтрация должна продолжать работать для registry-записей.
## Review Checklist
- [ ] Адаптер не ломает legacy симуляции
- [ ] Паритет 3 пилотов (open/stop/close/theory/preview)
- [ ] Соблюдены конвенции проекта (no emoji, .ic)
- [ ] Нет дублирования карточек
## Handoff to Next Phase
- Ядро `LabRegistry` готово (`frontend/js/labs/_registry.js`): register/get/has/all + setActive/stopActive/destroyActive + resolvePreview. Подключено первым.
- Адаптер активен в renderSims (lab-glue.js) и openSim/loadTheory/_pauseAllSims/closeSim (lab-init.js): реестр в приоритете, иначе legacy.
- Пилоты graph/quadratic/pendulum зарегистрированы в КОНЦЕ lab-init.js (после _openXxx). preview — ленивая функция (P_* из lab-glue, грузится позже). theory ссылается на объекты THEORY (единый источник).
- Пилоты НЕ удалены из SIMS/THEORY (сохранены для порядка и единого источника); merge перекрывает их по id. В Фазе 1 удалить legacy полностью.
- РЕВЬЮ-WARNING для Фазы 1: при переключении на LEGACY-симуляцию `_active` в реестре не очищается → следующий closeSim вызовет destroyActive() на уже неактивной. Для пилотов безвредно (idempotent stop). Очистить `_active` на legacy-open при миграции.
- Паттерн манифеста для Фазы 1: `{id,cat,title,desc,preview(fn|str),theory,open,stop,destroy}`. Регистрировать в самом sim-файле; следить за порядком загрузки (preview как fn спасает).
@@ -0,0 +1,45 @@
# Phase 1: Миграция всех симуляций на манифесты
**Status:** ✅ Done (ebb2a9b, reviewed PASS, pushed)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Перевести все ~49 симуляций на сам/регистрацию через `LabRegistry`. Перенести данные
(catalogue meta, preview, theory) и поведение (open/stop/destroy) в манифесты. Удалить
legacy-структуры. Сохранить глобальные имена через shim.
## Tasks
- [ ] Для каждой симуляции зарегистрировать манифест (метаданные из SIMS, preview из P_*, theory из THEORY, open/stop/destroy из _openXxx + _pauseAllSims/closeSim веток).
- [ ] Удалить массив `SIMS` (lab-glue.js) и объект `THEORY` (lab-init.js).
- [ ] Удалить if-цепочку `openSim`, `_pauseAllSims`, switch в `closeSim`, `ALL_SIM_BODIES`/`ALL_CTRL_BARS`.
- [ ] lab-init.js усохнуть до generic-логики (openSim/closeSim через реестр).
- [ ] Shim глобальных имён (gSim, pSim, …) — их дёргают deep-link/поиск/инлайн-обработчики.
- [ ] Сохранить обратную совместимость id (magnetic/coulomb→emfield, thinlens/mirrors/refraction→opticsbench, stereo:fig, hydrostatics:arg, molphys:arg, chemistry:arg, dynamics:arg, emfield:mode, opticsbench:mode).
## Files to Modify/Create
- Все `frontend/js/labs/*.js` симуляции — добавить `LabRegistry.register(...)`.
- `frontend/js/labs/lab-glue.js`, `frontend/js/labs/lab-init.js` — удалить legacy.
## Acceptance Criteria
- Все симуляции открываются/работают как раньше (паритет).
- Удалены все 6 точек дублирования из CONTEXT.md.
- Deep-links и алиасы работают.
## Notes
- Мигрировать пачками (по категориям) с проверкой паритета после каждой пачки (Big Bang допускает временную поломку между пачками).
- Превью с зависимостями (random в P_ELECTROLYSIS) перенести как есть.
## Review Checklist
- [ ] Ни одна симуляция не потеряна
- [ ] Глобальные shim'ы на месте
- [ ] Алиасы/deep-links работают
- [ ] Legacy полностью удалён
## Handoff to Next Phase
- РЕАЛИЗАЦИЯ ОТЛИЧАЕТСЯ ОТ ИСХОДНОГО ПЛАНА (осознанно): вместо ручного переписывания манифестов в каждом sim-файле сделана централизованная data-driven регистрация в `frontend/js/labs/_register-all.js`. Манифесты строятся из существующих SIMS (метаданные+preview) + THEORY (теория) + карта OPEN (40 обёрток над глобальными _openXxx). Это структурно гарантирует паритет каталога и диспетчеризации.
- УДАЛЕНО: if-цепочка openSim (~60 строк), _pilots.js. ДОБАВЛЕНО: LAB_SIM_ALIASES (magnetic/coulomb/thinlens/mirrors/refraction).
- НЕ УДАЛЕНО (осознанно, для паритета без браузера): SIMS (lab-glue) и THEORY (lab-init) остаются ИСТОЧНИКАМИ данных; _pauseAllSims()/closeSim() «дробовик» и ALL_SIM_BODIES/ALL_CTRL_BARS остаются — управляют скрытием тел и остановкой. Поэтому stop/destroy в манифестах НЕ заданы.
- ОСТАТОК ИЗ ПЛАНА (перенесено в Фазу 2+): полное удаление SIMS/THEORY/ALL_SIM_BODIES/ALL_CTRL_BARS и сворачивание lifecycle в манифесты (stop/destroy per-sim) — делать вместе с ленивым mount (Фаза 2), т.к. требует знания тел симуляций. Глобальные имена (gSim, pendSim…) НЕ трогались — shim не понадобился.
- ВЕРИФИКАЦИЯ: исполняемый vm-harness (40 регистраций + dispatch + алиасы + :arg) ALL PASS; независимое ревью PASS (coverage 40/40, dispatch parity, node --check). В БРАУЗЕРЕ НЕ ПРОВЕРЕНО.
- РИСК ДЛЯ ФАЗЫ 2: параллельная сессия активно правит lab.html (откатывала мои правки include дважды). Фаза 2 = вынос ~3000 строк ИЗ lab.html → высокий риск конфликтов. Координировать.
@@ -0,0 +1,37 @@
# Phase 2: Тела симуляций как шаблоны + ленивый mount
**Status:** ✅ Done (3f99d1b) — браузер-проверка пройдена (пользователь подтвердил: симуляции работают)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Вынести inline-HTML тел симуляций (`<div id="sim-xxx">`, ~3000 строк) из lab.html в
манифесты: `mount(host)` создаёт DOM лениво при первом открытии. lab.html худеет.
## Tasks
- [ ] Добавить в манифест поле `mount(host)` (или `bodyHtml`) — строит/возвращает тело симуляции.
- [ ] Ядро: при первом open — если тело не смонтировано, вызвать mount() в контейнер `#lab-sim`.
- [ ] Перенести разметку каждого `sim-xxx` тела + его `ctrl-xxx` бара из lab.html в соответствующий модуль.
- [ ] Удалить вынесенные блоки из lab.html.
- [ ] Сохранить id элементов (canvas ids, ctrl ids) — на них завязаны Sim-классы.
## Files to Modify/Create
- Все `frontend/js/labs/*.js` — добавить mount/bodyHtml.
- `frontend/lab.html` — удалить inline тела.
## Acceptance Criteria
- Все симуляции монтируются и работают.
- lab.html значительно меньше (~3000 строк вынесено).
- Повторное открытие не дублирует DOM.
## Notes
- Theory-panel и общий sim-topbar остаются в lab.html.
- KaTeX/lucide ре-инициализация после mount при необходимости.
## Review Checklist
- [ ] Нет дублей DOM при повторном open
- [ ] id элементов сохранены
- [ ] Все тела перенесены
## Handoff to Next Phase
<!-- заполнить после фазы -->
@@ -0,0 +1,46 @@
# Phase 3: Ленивая загрузка кода симуляций
**Status:** ✅ Done (70762be) — vm-harness + owner-in-files инвариант пройдены; нужна браузер-проверка
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Грузить тяжёлый код симуляции и движков по клику, а не 58 скриптов + three.js сразу.
Лёгкий манифест каталога загружается сразу.
## Tasks
- [ ] Вынести лёгкие метаданные каталога (id/cat/title/desc/preview) в отдельный `sims.manifest.js`, грузимый сразу.
- [ ] Тяжёлый код симуляции (Sim-класс + open/mount) грузить динамически при openSim (инъекция script или import()).
- [ ] Объявить зависимости движков в манифесте (`deps: ['_fx_core','_phys_visuals',...]`); загрузчик резолвит и грузит до кода симуляции, кешируя загруженное.
- [ ] three.js грузить только для 3D-симуляций (stereo).
- [ ] Лоадер с дедупликацией (один и тот же файл не грузится дважды).
## Files to Modify/Create
- `frontend/js/labs/_loader.js` — новый: динамический загрузчик + резолв зависимостей.
- `frontend/js/labs/sims.manifest.js` — новый: лёгкий каталог.
- `frontend/lab.html` — убрать массовые `<script>`, оставить ядро (api, registry, loader, manifest).
## Acceptance Criteria
- Первый рендер каталога без загрузки кода симуляций.
- Открытие симуляции догружает её код+движки и работает.
- three.js грузится только для 3D.
- Заметное падение объёма стартовой загрузки lab.html.
## Notes
- Учесть defer-скрипты (solutions/organic/periodic/qualanalysis).
- Кеш загруженных модулей в Map.
## Review Checklist
- [ ] Нет двойной загрузки
- [ ] Зависимости движков соблюдены
- [ ] Старт лаборатории легче
## Handoff to Next Phase
- РЕАЛИЗОВАНО: каркас (~530KB) грузится eager; код симуляций (~2.5MB) + three.js (~600KB) — лениво по клику. Старт /lab легче в ~6 раз.
- Файлы: `_loader.js` (LabLoader.ensure + кеш + self-heal), `_sim_deps.js` (СГЕНЕРИРОВАННЫЙ манифест SIM_DEPS + LAB_LAZY_FILES), правки `_register-all.js` (open→ensure.then), `lab-init.js` (openSim обрабатывает Promise), `lab.html` (eager сокращён до 12 labs-скриптов + framework).
- EAGER framework: _registry,_loader,_sim_deps,_fx_*,_graph_panel,_phys_visuals,_chem_visuals,_util,graph.js,lab-init,lab-glue,_register-all. graph.js eager (даёт GRID для 15 сим). _periodic_data — ЛЕНИВЫЙ (dep periodic).
- three.js — лениво, только для crystal/orbitals/stereo/periodic (THREE_SIMS).
- SELF-HEAL: если после загрузки манифестных файлов window[openName] не функция → грузятся ВСЕ ленивые файлы. Поэтому ошибка манифеста не ломает симуляцию (худший случай = поведение до Ф3).
- РЕГЕНЕРАЦИЯ МАНИФЕСТА: скрипт-генератор был временным (в %TEMP%), НЕ в репо. При добавлении/переименовании симуляции: либо вручную дописать SIM_DEPS в _sim_deps.js, либо восстановить генератор (логика в CONTEXT). СТОИТ положить генератор в tools/ при Фазе 4/5.
- БРАУЗЕР-ПРОВЕРКА (обязательна): открыть /lab → Network: на старте НЕ должно быть 45 sim-js и three.js; кликнуть pendulum (грузит pendulum.js), molphys (gas+states+brownian+diffusion), stereo (three.js+stereo.js), periodic (_periodic_data+periodic+three), chemistry (5 файлов), geometry, opticsbench; проверить deep-link stereo:cube, opticsbench:mirror, alias #magnetic.
- РИСК для Ф4/5: lab.html сильно изменён; параллельные сессии правят его — fetch+проверка перед работой.
@@ -0,0 +1,48 @@
# Phase 4: Реестр в БД + API + админка
**Status:** ✅ Done — review PASS, 11 тестов, миграция применена, запушено
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Хранить оверрайды каталога в БД, мёржить с код-манифестами, управлять каталогом из
админки (вкл/выкл, порядок, теги, рекомендуемые).
## Tasks
- [ ] Миграция БД: таблица `lab_sims` (id PK, title, cat, subject, grade, desc, enabled, sort, topic_id, textbook_ref, flags JSON, updated_at). Через `node:sqlite` DatabaseSync.
- [ ] Backend route `GET /api/lab/sims` — отдаёт мёрж: код-манифест (база) + БД-оверрайды.
- [ ] Backend admin routes: upsert/enable/disable/reorder/tag (под RBAC admin).
- [ ] Frontend: каталог берёт enabled/order/теги из `/api/lab/sims` (с фолбэком на код-манифест офлайн).
- [ ] Расширить `frontend/js/admin/sections/sims.js`: список, вкл/выкл, drag-reorder, теги, «рекомендуемые».
- [ ] Сохранить совместимость с `_disabledSimIds`.
## Files to Modify/Create
- `backend/src/db/migrations/0XX_lab_sims.sql` — новая миграция.
- `backend/src/routes/lab.js` (или расширить существующий) — API.
- `backend/src/server.js` — подключить роут (если новый файл).
- `frontend/js/admin/sections/sims.js` — расширить админку.
- `frontend/js/labs/_loader.js`/manifest — учитывать БД-данные.
## Acceptance Criteria
- `npm test` зелёный; `npm run lint:routes` без ошибок (auth на роутах).
- Админ может вкл/выкл/переупорядочить/тегировать симуляцию, изменения видны в каталоге.
- Офлайн/без БД — фолбэк на код-манифест.
## Notes
- RBAC: мутации только admin. Чтение каталога — для роли с доступом к лаборатории.
- Не дублировать данные: код-манифест = источник базовых полей; БД = оверрайды/доп.
## Review Checklist
- [ ] Миграция идемпотентна
- [ ] Роуты под auth (lint:routes)
- [ ] Мёрж корректен, фолбэк работает
- [ ] Тесты проходят
## Handoff to Next Phase
- РЕАЛИЗОВАНО: таблица `lab_sims` (миграция 042), `backend/src/routes/lab.js` (GET /api/lab/sims + PATCH /:id + POST /reorder), монтирование в server.js (require:58, mount:181), 11 тестов `lab-sims.test.js`, переписан `frontend/js/admin/sections/sims.js` (убран ADMIN_SIMS).
- ИСТОЧНИК ИСТИНЫ каталога теперь БД (lab_sims). preview-SVG остаются в коде. Поля subject/grade/featured/tags ГОТОВЫ в схеме и API — Фаза 5 их наполнит (курикулум) и фронт /lab может начать их потреблять.
- СОВМЕСТИМОСТЬ: enabled зеркалится в app_settings.sim_disabled_ids, поэтому lab.html (читает /api/settings/sims) скрывает выключенные без правок фронта. Каталог /lab пока НЕ читает /api/lab/sims (рендерится из кода-реестра+SIMS) — это опционально для Фазы 5: можно подтянуть порядок/featured/теги из БД.
- ТЕСТЫ: `npm test` имеет 3 PRE-EXISTING baseline-фейла (документированы с lab-split, не связаны с этой работой). pre-commit хук толерантен (BASELINE_FAILS=3). Мои 11 тестов проходят, +0 к фейлам.
- БРАУЗЕР-ПРОВЕРКА (желательно для Ф4): зайти в админку → раздел «Симуляции»: список грузится из API, тумблеры вкл/выкл и звёзда «рекомендуемая» работают; на /lab выключенная симуляция исчезает из каталога.
- ⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ активно коммитит в эту ветку (chemistry-8 и др.) — fetch+rebase перед push (в этой фазе было 2 behind, rebase прошёл чисто, без пересечений по файлам).
- Для Фазы 5: связи sim ↔ §учебника/тема/kmap можно класть в новую таблицу `lab_sim_links`; subject/grade уже есть в lab_sims для грубой привязки.
@@ -0,0 +1,43 @@
# Phase 5: Курикулумная привязка
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Связать симуляции с учебной программой: § учебника, узел knowledge-map, тема банка
вопросов. Двусторонняя навигация.
## Tasks
- [ ] Схема связей: использовать поля манифеста (subject/grade/topics) + таблицу связей `lab_sim_links` (sim_id, kind[textbook|topic|kmap|question], ref_id).
- [ ] API: `GET /api/lab/sims/:id/related` — связанные § / темы / задачи.
- [ ] Frontend учебник/теория: кнопка «Открыть в лаборатории» в § (deep-link openSim).
- [ ] Frontend knowledge-map: узел темы → ссылка на симуляцию.
- [ ] Страница симуляции: блок «Связанная теория и задачи».
- [ ] Админка: редактирование связей симуляции.
## Files to Modify/Create
- `backend/src/db/migrations/0XX_lab_sim_links.sql`
- `backend/src/routes/lab.js` — related endpoint.
- `frontend/textbooks.html` / theory / учебник-рендер — кнопки в §.
- `frontend/knowledge-map.html` — ссылки с узлов.
- `frontend/lab.html` — блок связей на странице sim.
- `frontend/js/admin/sections/sims.js` — редактор связей.
## Acceptance Criteria
- Из § учебника можно открыть нужную симуляцию.
- На странице симуляции видны связанные теория/задачи.
- Узлы knowledge-map ведут на симуляции.
- `npm test` зелёный, роуты под auth.
## Notes
- Привязки опциональны: отсутствие связей не ломает страницы.
- Переиспользовать существующие topic_id банка вопросов и структуру учебников.
## Review Checklist
- [ ] Навигация в обе стороны работает
- [ ] Пустые связи не ломают UI
- [ ] Роуты под auth, тесты проходят
## Handoff to Next Phase
<!-- финальная фаза -->
+417
View File
@@ -0,0 +1,417 @@
# План реализации: Химия 8 (Беларусь) — интерактивный наглядный учебник
> Цель: создать **с нуля** интерактивный наглядный учебник по всей программе 8 класса
> в **современной архитектуре hub + главы** (как Физика 7–11 / Алгебра / Геометрия —
> НЕ как легаси-монолит `chemistry_9.html`), на уровне их качества, с поправкой на
> содержание 8 класса: **количественные понятия (моль, M, Vm, расчёты по уравнениям),
> классы неорганических соединений, периодический закон, строение атома, химическая
> связь, ОВР, растворы.**
>
> **Архитектура (утверждена):** `chemistry_8_hub.html` (хаб-каталог глав) + **7 файлов глав**
> (вводный раздел + 6 глав книги). В БД — родитель `chemistry-8` + 7 детей с `parent_slug`.
> Каждая глава — самостоятельная страница со своими § (см. карту ниже), модульный
> CSS/JS на предмет. Единый стандарт с планом Химии 9 ([[plans/textbooks-9/PLAN_CHEMISTRY_9.md]]).
---
## 🎯 Источник
| Параметр | Значение |
|----------|----------|
| Книга | `himiya_8kl_shimanovich_rus_2018 (1).pdf` |
| Авторы | Шиманович И. Е., Красицкий В. А., Сечко О. И., Хвалюк В. Н. |
| Изд. | Минск, «Народная асвета», 2018, 243 с. (тираж 116 000) |
| Структура | **Вводный раздел + 6 глав, 52 §, 4 лабораторных опыта, 4 практические работы** |
| Справочные таблицы | ПСХЭ Менделеева, таблица растворимости, ряд активности металлов (форзацы) |
PDF лежит в `G:\Dev\Тесты\Методички\тест_6 класс\Книги\`. Оглавление — стр. 238–239 PDF.
> **Важные отличия от Химии 9:**
> 1. Страниц химии-8 **ещё нет** — строим с нуля. Каркас берём у **современных hub-учебников**
> (`physics_9_hub.html` + `physics_9_chN.html`), а НЕ у легаси-монолита `chemistry_9.html`.
> 2. Описательной химии металлов/неметаллов в 8 классе **нет** (это 9 класс) — акцент на
> количественные расчёты, классификацию веществ, строение атома и химическую связь, ОВР.
>
> **Соответствие «раздел книги → файл главы → slug»:**
>
> | Раздел книги | § | Файл | slug | Цвет |
> |---|---|---|---|---|
> | Вводный: Количественные понятия | 19 | `chemistry_8_intro.html` | `chemistry-8-intro` | amber |
> | Гл.1 Классы неорг. соединений | 1023 | `chemistry_8_ch1.html` | `chemistry-8-ch1` | teal |
> | Гл.2 Периодический закон и ПСХЭ | 2428 | `chemistry_8_ch2.html` | `chemistry-8-ch2` | indigo |
> | Гл.3 Строение атома | 2935 | `chemistry_8_ch3.html` | `chemistry-8-ch3` | blue |
> | Гл.4 Химическая связь | 3641 | `chemistry_8_ch4.html` | `chemistry-8-ch4` | green |
> | Гл.5 ОВР | 4245 | `chemistry_8_ch5.html` | `chemistry-8-ch5` | deep-orange |
> | Гл.6 Растворы | 4652 | `chemistry_8_ch6.html` | `chemistry-8-ch6` | cyan |
>
> Хаб: `chemistry_8_hub.html` / slug `chemistry-8` (родитель в каталоге).
---
## 📗 ПОЛНАЯ КАРТА СОДЕРЖАНИЯ (52 §)
Колонка **«Интерактив»** — главный наглядный элемент сверх текста (минимум 1 «звёздный»
виджет на §; полный набор — в стандарте ниже).
### ВВОДНЫЙ РАЗДЕЛ. Повторение курса 7 класса. Количественные понятия в химии (§§1–9) — *amber*
| § | Тема | Ключ | Интерактив (звёздный виджет) |
|---|------|------|------------------------------|
| §1 | Атомы. Химические элементы. Относительная атомная масса | $Z$, символ, $A_r$ | **miniPeriodic** + поиск $A_r$ по элементу; модель атома |
| §2 | Молекулы. Простые/сложные вещества. Химические формулы. $M_r$ | формула, индексы, $M_r=\sum A_r$ | **Конструктор формул** + калькулятор $M_r$ (biochem-core) |
| §3 | Химическое количество вещества | понятие «порция», $n$ | Визуализация «порции вещества» (частицы → моль) |
| §4 | Моль — единица количества вещества. Постоянная Авогадро | $N=n\cdot N_A$, $N_A=6{,}02\cdot10^{23}$ | **Счётчик частиц** $N\leftrightarrow n$, масштаб $N_A$ |
| §5 | Молярная масса. Молярный объём газов | $M$ (г/моль), $V_m=22{,}4$ л/моль (н.у.) | Калькулятор $M$ + газовая модель ($V_m$) |
| §6 | Вычисление $n$ по $m$ и $m$ по $n$ | $n=\dfrac{m}{M}$ | **Треугольник $n$–$m$–$M$** (интерактивный калькулятор-тренажёр) |
| §7 | Вычисление $n$ газа по $V$ и $V$ по $n$ | $n=\dfrac{V}{V_m}$ | Калькулятор $V=n\cdot V_m$ + связка $m$$n$$V$$N$ |
| §8 | Химические реакции | признаки, закон сохранения массы, балансировка | **Балансировщик уравнений** (анимация коэффициентов) + классификатор типов реакций |
| §9 | Количественные расчёты по уравнениям реакций | стехиометрия, мольные отношения | **sim `stoichiometry`** + пошаговый решатель «дано → по уравнению» |
**Практическая работа 1** (после §7): «Химическое количество вещества».
### ГЛАВА 1. Важнейшие классы неорганических соединений (§§1023) — *teal/cyan*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §10 | Оксиды. Состав и классификация | $Э_xO_y$; осн./кисл./амфот./несолеобр. | **Классификатор оксидов** (drag формулы → класс) + конструктор формул оксидов по валентности |
| §11 | Химические свойства оксидов | осн.оксид+кислота/вода; кисл.оксид+щёлочь/вода | **Матрица реакций оксидов** (`chemEq`, признаки) |
| §12 | Получение и применение оксидов | горение, разложение; применение | Схемы получения + инфографика применения |
| §13 | Кислоты. Состав и классификация | $H_xAc$; бескисл./кислородсод., основность | **Классификатор кислот** + `indicatorScale` (лакмус/метилоранж) |
| §14 | Химические свойства кислот | + Me (ряд активности), + осн.оксид, + основание, + соль | **Реакции кислот** (4 типа) + ряд активности + `indicatorScale` |
| §15 | Получение и применение кислот | кисл.оксид+вода, соль+кислота | Схемы получения + инфографика |
| §16 | Основания | $Me(OH)_n$; щёлочи/нерастворимые | Конструктор $Me(OH)_n$ + `indicatorScale` (фенолфталеин малиновый) |
| §17 | Химические свойства оснований | нейтрализация, +кисл.оксид, +соль, разложение | **Реакция нейтрализации** (анимация) + `indicatorScale` |
| §18 | Получение и применение оснований | Me+вода, щёлочь+соль | Схемы; **Лаб.1**: получение нерастворимого основания (`testTube` $Cu(OH)_2$↓ голубой) |
| §19 | Соли. Состав и классификация | катион×анион; средние/кислые/основные | **Конструктор солей** (катион×анион) + `solubilityTable` |
| §20 | Химические свойства солей | РИО (↓↑), соль+Me (ряд активности) | `solubilityTable` + предсказатель РИО; **Лаб.2**: соли+металлы |
| §21 | Получение и применение солей | 8+ способов получения | **Матрица способов получения солей** |
| §22 | Взаимосвязь между классами неорг. веществ | генетическая связь Me/неMe → оксид → гидроксид → соль | **Генетическая карта-граф** (интерактивные переходы) |
| §23 | Решение расчётных задач по теме | расчёты по классам, по уравнениям | **sim `stoichiometry`** + тренажёр расчётов |
**Лаб. опыт 1** (после §18): получение нерастворимого основания.
**Практическая работа 2** (после §18): изучение реакции нейтрализации.
**Лаб. опыт 2** (после §20): взаимодействие растворов солей с металлами.
**Практическая работа 3** (после §22): решение экспериментальных задач.
### ГЛАВА 2. Периодический закон и периодическая система (§§2428) — *indigo/violet*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §24 | Систематизация химических элементов | ранние классификации, металлы/неметаллы | Сортировщик элементов (Me/неMe/амфот.) |
| §25 | Понятие об амфотерности | $Zn(OH)_2, Al(OH)_3$ + кислота **и** + щёлочь | **Амфотерность** (`testTube`, обе реакции); **Лаб.3**: гидроксид цинка |
| §26 | Естественные семейства элементов | щелочные, ЩЗМ, галогены, инертные | **miniPeriodic** — подсветка семейств, тренды свойств |
| §27 | Периодический закон Д. И. Менделеева | формулировка, периодичность | **Демонстрация периодичности** (карточки-раскладка элементов) |
| §28 | Периодическая система химических элементов | период/группа/подгруппа, структура | **Интерактивная ПСХЭ** (sim `periodic`) + разбор структуры |
**Лаб. опыт 3** (после §25): получение гидроксида цинка и изучение амфотерных свойств.
### ГЛАВА 3. Строение атома и периодичность свойств (§§29–35) — *blue*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §29 | Строение атома. Атомный номер | ядро ($p^+,n^0$) + $e^-$, $Z$ | **Модель атома** (sim `bohratom`) — сборка по $Z$ |
| §30 | Массовое число атома. Нуклиды | $A=Z+N$, нуклид | Калькулятор $A=Z+N$ + конструктор нуклида |
| §31 | Изотопы. Явление радиоактивности | одинаковый $Z$, разный $N$; распад | Изотопы-конструктор + sim `radioactive` + расчёт $A_r$ по изотопам |
| §32 | Состояние электронов. Электронное облако. Орбиталь | $s,p,d$ орбитали, форма облака | **3D-облака орбиталей** (sim `orbitals`) |
| §33 | Строение электронных оболочек атомов | уровни, $2n^2$, конфигурация | **Конструктор электронной конфигурации** (`orbitalDiagram`, заполнение) |
| §34 | Периодичность изменения свойств атомов | радиус, ЭО, металличность по периоду/группе | **Графики трендов** (slider период/группа → свойство) |
| §35 | Характеристика элемента по положению в ПС | алгоритм «паспорта» элемента | **Генератор «паспорта элемента»** (пошагово) |
### ГЛАВА 4. Химическая связь (§§36–41) — *green*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §36 | Природа химической связи | октет, энергия связи, устойчивость | Анимация «почему атомы соединяются» |
| §37 | Ковалентная связь | общие электронные пары, схемы Льюиса | **Конструктор e-пар** + структурные формулы (biochem-core) |
| §38 | Неполярная и полярная ков. связь. ЭО | $\Delta$ЭО → полярность, диполь | **Slider ЭО → тип связи** + диполь; **Лаб.4**: модели молекул (biochem-core 3D) |
| §39 | Ионная связь | передача $e^-$, ионная решётка | **Анимация $Na\to Cl$** + решётка $NaCl$ |
| §40 | Металлическая связь. Межмолекулярное взаимодействие | «электронный газ», водородная связь | **Модель электронного газа** + водородная связь |
| §41 | Кристаллическое состояние вещества | 4 типа решёток → свойства | **4 типа решёток** (3D) + связь «тип → свойства» |
**Лаб. опыт 4** (после §38): составление моделей молекул.
### ГЛАВА 5. Окислительно-восстановительные реакции (§§4245) — *deep-orange*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §42 | Степень окисления | правила, расчёт по формуле | **Калькулятор степени окисления** (любая формула) |
| §43 | Процессы окисления и восстановления | отдача/приём $e^-$, окислитель/восстановитель | Визуализация переноса $e^-$ |
| §44 | Окислительно-восстановительные реакции | метод электронного баланса | **Балансировщик ОВР** (пошаговый e-баланс) |
| §45 | ОВР вокруг нас | горение, коррозия, дыхание, батарейки | Инфографика-исследование ОВР в жизни |
### ГЛАВА 6. Растворы (§§4652) — *cyan*
| § | Тема | Ключ | Интерактив |
|---|------|------|------------|
| §46 | Смеси веществ | однородные/неоднородные, разделение | Классификатор смесей + методы разделения |
| §47 | Растворение веществ в воде | гидратация, тепловой эффект | **Анимация растворения** (`dissociationAnim`) |
| §48 | Характеристики растворимости веществ | $s=f(t)$, насыщ./ненасыщ. | **График растворимости** + `solubilityTable` |
| §49 | Качественные характеристики состава растворов | насыщ./ненасыщ./разб./конц. | Качественная шкала «крепости» раствора |
| §50 | Количественные характеристики. Массовая доля | $w=\dfrac{m_{в-ва}}{m_{р-ра}}$ | **Калькулятор $w$** (sim `solutions`) |
| §51 | Молярная концентрация растворённых веществ | $c=\dfrac{n}{V}$, разбавление, смешение | **Калькулятор $c$** + разбавление/смешение |
| §52 | Вода и растворы в жизни и деятельности человека | значение, очистка, быт | Инфографика-исследование |
**Практическая работа 4** (после §51): приготовление раствора с заданной $w$ и $c$.
**Итого**: 52 §, вводный раздел + 6 глав, **4 лаб. опыта** (§18, §20, §25, §38), **4 практические работы** (§7, §18, §22, §51).
---
## ⚗️ ХИМИЧЕСКИЙ СТАНДАРТ КАЧЕСТВА
### A. Движки и переиспользуемые активы (всё уже есть в проекте)
| Что нужно | Берём из | Файл / id |
|-----------|----------|-----------|
| Парсинг формул, $M$/$M_r$, формула Хилла | biochem-core | `frontend/js/biochem-core.js` ✅ |
| 2D/3D шаростержневые модели, VSEPR | biochem-core | `frontend/js/biochem-core.js` ✅ |
| Интерактивная ПСХЭ | sim `periodic` | реестр `_register-all.js` ✅ |
| Модель атома (Бор) | sim `bohratom` | реестр ✅ |
| Орбитали | sim `orbitals` | реестр ✅ |
| Радиоактивность/изотопы | sim `radioactive` | реестр ✅ |
| Стехиометрия | sim `stoichiometry` | реестр ✅ |
| Титрование (нейтрализация) | sim `titration` | реестр ✅ |
| Качественный анализ | sim `qualanalysis` | реестр ✅ |
| Растворы / массовая доля | sim `solutions` | реестр ✅ |
| Песочница реакций | sim `chemsandbox` | реестр ✅ |
Монтаж: контейнер `<div id="sim-<id>"></div>` + `openSim('<id>')` (или прямой mount
через `window.LabRegistry`), как на остальных страницах.
### B. Общий хелпер `/js/chem8_svg.js` (по образцу `geom7_svg.js`, `alg10_svg.js`)
> **Рекомендация:** химические примитивы 8 и 9 классов сильно пересекаются. Реализовать
> файл так, чтобы его можно было продвинуть в **общий `/js/chem_svg.js`** (план Химии 9
> ссылается на `chem9_svg.js` — при совместной разработке свести в один shared-модуль и
> переиспользовать оба). Молекулы — **только через `biochem-core.js`**, не дублировать.
```js
// 1. Рендер уравнения реакции: коэффициенты, состояния (↑↓), стрелки, условия над стрелкой
const chemEq = (src, opts={}) => { /* токенизация формул, верхн./нижн. индексы, →/⇌/→[t°] */ };
// 2. Ион с зарядом: ionLabel('SO4', -2) → 'SO₄²⁻'
const ionLabel = (formula, charge) => { /* нижние индексы + надстрочный заряд */ };
// 3. Пробирка с осадком/газом/окраской (SVG-анимация)
const testTube = ({fill, precipitate, gas, color, label}) => { /* svg */ };
// 4. Треугольник n–m–M (звёздный виджет §6): кликаешь искомое → формула + калькулятор
const moleTriangle = (mount, {solveFor}) => { /* n=m/M, m=n·M, M=m/n */ };
// 5. Балансировщик уравнений (§8): подбор коэффициентов, проверка баланса атомов
const equationBalancer = (mount, {skeleton}) => { /* матрица атомов, подсветка дисбаланса */ };
// 6. Калькулятор степени окисления (§42): формула → с.о. каждого элемента (правила)
const oxStateCalc = (mount, {formula}) => { /* разбор, правила H+1/O−2/Σ=0 */ };
// 7. Балансировщик ОВР методом e-баланса (§44): полуреакции, НОК, коэффициенты
const redoxBalancer = (mount, {skeleton}) => { /* окислитель/восстановитель, Δe⁻ */ };
// 8. Орбитальная диаграмма (§33): orbitalDiagram('1s2 2s2 2p4') → клетки + ↑↓
const orbitalDiagram = (config) => { /* svg клетки, принцип Хунда/Паули */ };
// 9. Интерактивная таблица растворимости (§19,20,48): подсветка пары катион×анион (Р/Н/М/—)
const solubilityTable = (mount, {highlight}) => { /* из форзаца книги */ };
// 10. Интерактивный ряд активности металлов (§14,20): клик → предсказание реакции
const activitySeries = (mount, opts) => { /* K Ca Na Mg Al Zn Fe ... Au + (H₂) */ };
// 11. Мини-ПСХЭ с подсветкой (§1,26,34): элемент/группа/период/семейство
const miniPeriodic = (mount, {highlight, onClick}) => { /* интерактивная сетка */ };
// 12. Индикатор + шкала pH (§13,14,16,17): лакмус/фенолфталеин/метилоранж
const indicatorScale = (mount, {ph, indicator}) => { /* цвет полоски */ };
// 13. Анимация растворения/гидратации (§47): частицы воды окружают ионы/молекулы
const dissociationAnim = (mount, {substance}) => { /* canvas/SVG-частицы */ };
// 14. Классификатор-DnD (§10,13,16,19,46): drag формулы → класс/тип; проверка
const classifier = (mount, {items, buckets}) => { /* оксиды/кислоты/основания/соли/смеси */ };
// 15. Генетическая карта-граф (§22): Me/неMe → оксид → гидроксид → соль, клик-переходы
const geneticMap = (mount, opts) => { /* SVG-граф классов + рендер реакции перехода */ };
```
### C. Правила рендера химии (обязательны с §1)
1. **Формулы веществ** — нижние индексы для атомов ($H_2O$, $CaCO_3$), верхние для зарядов
ионов ($SO_4^{2-}$) и степеней окисления ($\overset{+2}{Ca}$); единый рендер через
`chemEq`/`ionLabel`, не «сырой» текст.
2. **Уравнения реакций** — всегда сбалансированы; стрелки `=`/`→` (необратимая),
`⇌` (обратимая), `↑` (газ), `↓` (осадок), условия над стрелкой ($t$, кат., эл.ток).
3. **Состояние/признак** — для качественных реакций показывать цвет осадка, пузырьки газа,
изменение окраски индикатора (через `testTube`/`indicatorScale`).
4. **Количественные расчёты** — каждый расчётный § даёт калькулятор/тренажёр с пошаговым
решением (дано → формула → подстановка → ответ с единицами), не только готовый ответ.
5. **Молекулярные модели** — структурная формула + 3D (biochem-core) для каждой изучаемой
молекулы (§37–38, §41); для типов решёток — 3D-ячейки.
6. **Цвета — химически достоверные**: осадки ($Cu(OH)_2$ голубой, $Fe(OH)_3$ бурый,
$Zn(OH)_2$ белый); индикаторы (фенолфталеин в щёлочи малиновый, лакмус в кислоте красный,
метилоранж в кислоте розовый).
7. **Безопасность** — где уместно (растворение кислот/щелочей, разбавление) — заметка-«скрепка».
8. **KaTeX-эскейпы** — в JS-шаблонах двойной backslash (`\\to`, `\\downarrow`, `\\rightleftharpoons`).
9. **Drag/слайдеры**`window`-listeners + `{passive:false}` + state ВЫШЕ `redraw()`
(стандарт геометрии), `touch-action:none` на draggable SVG/canvas, **без `setPointerCapture`**.
10. **Без эмоджи** — только inline SVG `.ic`/`.ico` (правило проекта [[feedback_no_emoji]]).
### D. Типы интерактивов по темам 8 класса
| Тип темы | Интерактив |
|----------|------------|
| Количество вещества (§3–7, 9) | `moleTriangle`, калькуляторы $M/V_m/N$, sim `stoichiometry` |
| Химические реакции (§8) | `equationBalancer`, классификатор типов |
| Классы соединений (§10–21) | `classifier`, матрицы реакций, `indicatorScale`, `solubilityTable`, `activitySeries` |
| Генетическая связь (§22) | `geneticMap` |
| Периодический закон / ПСХЭ (§24–28, 34) | `miniPeriodic`, sim `periodic`, графики трендов |
| Строение атома (§29–33) | sim `bohratom`, sim `orbitals`, sim `radioactive`, `orbitalDiagram` |
| Химическая связь (§36–41) | slider ЭО, biochem-core 3D, ионная/металлич. решётка, 4 типа кристаллов |
| ОВР (§4244) | `oxStateCalc`, `redoxBalancer`, визуализация переноса $e^-$ |
| Растворы (§46–52) | `dissociationAnim`, график растворимости, sim `solutions`, калькулятор $c$ |
| Качественные/амфотерность (§18,25) | `testTube` + уравнение + признак |
---
## 📦 СТРУКТУРА КАЖДОГО § (стандарт наполнения)
**Теория (3–4 карточки):**
- `theory` — основное определение/понятие + наглядная SVG/модель
- `rule` — ключевая закономерность/формула (рамка)
- `example` — разобранный пример (реакция/расчёт) с пошаговым рендером
- (для прикладных §) `apply` — применение/значение (инфографика)
**Интерактивы (4–6 на §):**
1. **Звёздный виджет** темы (из карты содержания)
2. **Конструктор/симулятор** (slider / drag / sim из реестра)
3. **Калькулятор** ($M$, $n$, $w$, $c$, с.о., по уравнению) — где применимо
4. **DnD-классификатор** (классы веществ, тип связи/реакции, тип решётки)
5. **Тренажёр** — 5 задач с inline-наглядностью (формула/уравнение/модель в условии)
6. **Босс §** — 4 интеграционные задачи (+5 XP каждая)
**Дополнительно:** пополнение глоссария (термины §, `[[ссылки]]`), «Вопросы и задания»
из учебника (адаптированные, с проверкой), проходящий jsdom-тест страницы.
**Финал главы:** итоговая шпаргалка (mini-cards), карта связей (SVG-граф понятий),
7 интегрированных боссов (+10 XP), achievement «Мастер главы N» (+50 XP, confetti),
кнопка перехода к следующей главе.
---
## 🚀 ПОРЯДОК РЕАЛИЗАЦИИ (по фазам)
### Phase 0 — Фундамент (hub + каркасы глав)
- **`chemistry_8_hub.html`** — хаб-каталог 7 глав по образцу `physics_9_hub.html`: палитра
**amber**, водяной знак «ХИМИЯ», карточки глав с прогрессом (грузится из
`/api/textbooks/chemistry-8/children`), блок «Финал курса» (шпаргалка + боссы — наполняется в Phase 7),
achievement-strip «Химик 8 класса», тема (localStorage `chemistry8_theme`).
- **7 файлов глав** `chemistry_8_intro.html` + `chemistry_8_ch1..ch6.html` — на Phase 0
валидные каркасы-заглушки (header с водяным знаком, hero, sidebar-оглавление §, контейнер
параграфов, XP/tracker-интеграция), наполнение § — в Phase 1–6.
- **`/js/chem8_svg.js`** (хелперы B — заглушки → реализация по фазам).
- Подключить `biochem-core.js` + нужные симуляторы на страницах глав.
- **Миграция `041_chemistry8_hub.sql`** (следующий номер после `040_content_access.sql`):
**INSERT** родителя `chemistry-8` (`html_path='chemistry_8_hub.html'`, `para_count=52`,
`color='amber'`, `parent_slug=NULL`) + **7 детей** `chemistry-8-intro`/`-ch1..-ch6`
(`parent_slug='chemistry-8'`, свои `html_path`/`para_count`/`color`/`sort_order`) — по образцу
`038_physics_9_hub.sql`. Применить `npm run migrate`.
- jsdom-тест-каркас: хаб строится, все 8 файлов парсятся, ссылки глав ведут на существующие slug.
### Phase 1 — Вводный раздел «Количественные понятия» (§§1–9) + ПР1 — фундамент расчётов
Базовые движки: калькулятор $M_r$ (biochem-core), `moleTriangle`, связка $m$$n$$V$$N$,
`equationBalancer`, sim `stoichiometry`. **Критично** — эти расчёты используются во всех главах.
### Phase 2 — Глава 1 «Классы неорганических соединений» (§§10–23) + Лаб.1,2 + ПР2,3
Самая объёмная. Закладываем `classifier`, `indicatorScale`, `solubilityTable`,
`activitySeries`, матрицы реакций, `testTube` (первые качественные/нейтрализация),
`geneticMap` (§22), sim `titration`.
### Phase 3 — Глава 2 «Периодический закон и ПСХЭ» (§§24–28) + Лаб.3
`miniPeriodic`, sim `periodic`, амфотерность ($Zn(OH)_2$ — обе реакции + `testTube`),
демонстрация периодичности.
### Phase 4 — Глава 3 «Строение атома» (§§29–35)
sim `bohratom`, sim `orbitals`, sim `radioactive`, `orbitalDiagram`, графики периодических
трендов, генератор «паспорта элемента».
### Phase 5 — Глава 4 «Химическая связь» (§§36–41) + Лаб.4
slider ЭО → тип связи, biochem-core 3D-модели, ионная/металлическая решётки,
4 типа кристаллических решёток (3D) и связь «тип → свойства».
### Phase 6 — Глава 5 «ОВР» (§§42–45) + Глава 6 «Растворы» (§§46–52) + ПР4
`oxStateCalc`, `redoxBalancer`, визуализация переноса $e^-$; затем `dissociationAnim`,
график растворимости, sim `solutions`, калькулятор $c$, ПР4.
### Phase 7 — Финалы глав + общий финал учебника
Шпаргалки и карты связей по каждой главе; интегрированные боссы + achievements;
**большой финал**: генетическая карта классов + строение/связь, итоговый босс-квест,
ачивка «Химик 8 класса»; глоссарий собран и связан `[[ссылками]]`.
### Phase 8 — Качество и админка
Полный прогон jsdom-тестов (каждый § — builder не stub); аудит баланса уравнений и
KaTeX/`chemEq`-эскейпов; синхронизация с админкой (если новые sim в `lab.html`
обновить `ADMIN_SIMS` в `admin.html` — [[feedback_sims_admin_sync]]); проверка доступа
по классам/ученикам ([[project_content_access]], `/api/access`).
> Рекомендуемый темп: внутри фазы — по 2–3 § за «волну», каждая волна = commit +
> проходящий jsdom-тест (правило CLAUDE.md: commit изменённых файлов + push).
---
## 🗄️ ИНТЕГРАЦИЯ С ПРОЕКТОМ
| Точка | Действие |
|-------|----------|
| **БД каталог** | `chemistry-8` в `textbooks` **отсутствует** → миграция `041_chemistry8_hub.sql`: INSERT родитель + 7 детей (образец — `038_physics_9_hub.sql`). Каталог `/api/textbooks` показывает только `parent_slug IS NULL`; хаб тянет детей через `/api/textbooks/chemistry-8/children`. |
| **Прогресс/XP** | Автоматически: `textbook-xp-widget.js` (+5 XP/§), `textbook-tracker.js`, `LS.xp`. Доп. XP за боссов — по образцу `phys7_ch1_widgets.js`. |
| **Симуляторы** | Реестр `frontend/js/labs/_register-all.js`. Нужные химические sim уже зарегистрированы: `periodic`, `bohratom`, `orbitals`, `radioactive`, `stoichiometry`, `titration`, `qualanalysis`, `solutions`, `chemsandbox`. |
| **Молекулы** | `biochem-core.js` (парсинг, $M$, 2D/3D, VSEPR). |
| **Бэкенд** | Роуты готовы: `backend/src/routes/textbooks.js` (catalog/progress/bookmarks). Доступ: `backend/src/services/contentAccess.js`. |
| **Глоссарий** | Виджет всплывающих определений на странице (общего нет — реализовать). |
| **Тесты** | `cd backend && npm test` (jsdom). На каждый § — тест: страница строится, builder не stub, уравнения сбалансированы. |
| **Админка** | Новые sim в `lab.html` → синхронно `ADMIN_SIMS` в `admin.html`. |
---
## ⚠️ КРИТИЧЕСКИЕ ПРАВИЛА
### ❌ НЕ делать
- «Сырые» формулы текстом — только `chemEq`/`ionLabel`/KaTeX.
- Несбалансированные уравнения (аудит баланса перед commit).
- Дублировать молекулярный движок — использовать `biochem-core.js`.
- `setPointerCapture` (теряется после `innerHTML`-replace) → `window`-listeners + state-flag.
- `\to`, `\downarrow`, `\rightleftharpoons` без удвоения backslash в JS-шаблонах.
- Slider-диапазоны за пределы химически возможного (концентрации, температуры, $V_m$).
- Эмоджи — запрещены; только inline SVG `.ic`.
- **Grep tool — запрещён**; поиск только `ast-index` ([[reference_sqlite_node]] и правила проекта).
### ✅ Обязательно
- Каждый commit → jsdom-тест 100% pass.
- Аудит баланса уравнений + KaTeX-эскейпов после каждой волны.
- Расчётный § = калькулятор/тренажёр с **пошаговым** решением и единицами измерения.
- Качественная реакция = уравнение (молек.+, где есть, ионное) **+ видимый признак**.
- Цвета осадков/индикаторов — химически достоверные.
- Все builder-функции в конце финальной волны главы — НЕ stub'ы.
- Коммитить только изменённые файлы (не `git add -A`), сразу push.
---
## 📊 Оценка объёма
| Раздел | § | Лаб/ПР | Ожидаемый LOC |
|--------|---|--------|---------------|
| Вводный (кол-во вещества) | 9 | ПР1 | ~7 000 (+`moleTriangle`, `equationBalancer`, `stoichiometry`) |
| Гл.1 Классы соединений | 14 | Лаб1,2 + ПР2,3 | ~16 000 (+`classifier`, `solubilityTable`, `activitySeries`, `geneticMap`) |
| Гл.2 ПЗ и ПСХЭ | 5 | Лаб3 | ~5 000 (+`miniPeriodic`, амфотерность) |
| Гл.3 Строение атома | 7 | — | ~7 000 (+`orbitalDiagram`, sim bohratom/orbitals/radioactive) |
| Гл.4 Химическая связь | 6 | Лаб4 | ~6 500 (+3D-модели, 4 решётки) |
| Гл.5 ОВР | 4 | — | ~4 500 (+`oxStateCalc`, `redoxBalancer`) |
| Гл.6 Растворы | 7 | ПР4 | ~7 000 (+`dissociationAnim`, sim solutions, калькулятор $c$) |
| Финалы глав + общий | — | — | ~5 000 |
| `/js/chem8_svg.js` хелперы | — | — | ~3 000 |
| Хаб + 7 каркасов глав (Phase 0) | — | — | ~3 000 |
| **Итого** | **52** | **4 лаб + 4 ПР** | **~64 000 LOC** |
---
## 🎬 Запуск
**Phase 0**: `chemistry_8_hub.html` (по образцу `physics_9_hub.html`) + 7 каркасов глав
(`chemistry_8_intro.html`, `chemistry_8_ch1..ch6.html`) + `/js/chem8_svg.js` (скелет) +
подключение `biochem-core.js`/симуляторов + миграция `041_chemistry8_hub.sql` (родитель + 7 детей)
+ `npm run migrate` + jsdom-каркас.
**Phase 1**: Вводный раздел (§§1–9) — закладываем движки расчётов (`moleTriangle`,
`equationBalancer`, sim `stoichiometry`), от которых зависят все главы.
Дальше — последовательно по главам (Phase 2 → 6), затем финалы (Phase 7) и качество (Phase 8).
@@ -0,0 +1,102 @@
# План апгрейда: Химия 8 — больше интерактива и наполнения
> База готова: вводный раздел + 6 глав, все 52 §, движок `chem8_engine.js` + 12 виджетов,
> 37 тестов. Этот план — **следующий уровень**: финал курса, глоссарий, новые движки-виджеты,
> 3D-модели молекул, обогащение контента и финалов глав, синхронизация с админкой.
Принципы (как в базовом плане): эталонная SPA-структура, без эмоджи (только inline SVG `.ic`),
KaTeX-эскейпы, jsdom-проверка каждого нового виджета, поиск через `ast-index`, изоляция
химии на ветке `feature/chemistry-8` (cherry-pick из рабочей ветки).
---
## U1 — Финал курса в хабе (Phase 7) ⭐ старт
`chemistry_8_hub.html` сейчас содержит заглушку «Финал курса появится позже». Заменить на
полноценный финал по образцу `physics_9_hub.html`:
- **Шпаргалка курса** — 7 cheat-cards (вводный + 6 глав) с ключевыми формулами/реакциями.
- **10 интегрированных боссов** — задачи, каждая связывает ≥2 раздела (например, «масса осадка
по уравнению РИО», «c раствора + расчёт по уравнению»). +15 XP за босса.
- **Ачивка «Химик 8 класса»** — при всех 10 → +150 XP, confetti, CTA «К каталогу».
- Прогресс-бар боссов, lazy-render при раскрытии аккордеона, localStorage
(`chemistry8_course_bosses`, `chemistry8_course_master`).
- jsdom-тест: финал раскрывается, 10 боссов рендерятся, KaTeX, без ошибок.
## U2 — Глоссарий (Phase 8a)
Единый виджет всплывающих определений терминов на всех 8 страницах:
- `chem8_glossary.js` — словарь ~120 терминов (оксид, кислота, основание, соль, моль, валентность,
степень окисления, электроотрицательность, изотоп, орбиталь, растворимость, концентрация …).
- Авто-подсветка терминов в тексте `.card-body` (`<abbr class="gloss" data-term="…">`) +
popover с определением и `[[ссылками]]` на связанные термины.
- Кнопка «Глоссарий» в header каждой главы → модальное окно со списком/поиском.
- Тест: словарь парсится, термин даёт определение.
## U3 — Новые движки-виджеты (chem8_svg.js)
Заменить оставшиеся заглушки реальными реализациями + добавить новые:
| Виджет | § | Что делает |
|--------|---|------------|
| `dissociationAnim` | §47, ТЭД | анимация распада соли/кислоты на ионы в воде (canvas/SVG-частицы) |
| `geneticMap` | §22 | интерактивный граф классов (Me→оксид→основание→соль), клик по ребру → реакция |
| `redoxBalancer` | §44 | общий балансировщик ОВР методом e-баланса (не преднабор) |
| `reactionMatrix` | §11,14,17,20 | матрица «реагент × реагент» → продукт/нет реакции |
| `phScale` | §13,16 | расширенная шкала pH с примерами бытовых веществ |
| `ionConverter` | §9,РИО | молекулярное → полное ионное → сокращённое ионное уравнение |
Каждый — с jsdom-смоук-тестом монтажа и расчёта.
## U4 — 3D-модели молекул (biochem-core)
Интегрировать `biochem-core.js` (window.BIO — 2D/3D шаростержневые модели, VSEPR):
- §37–38 — модели H₂, Cl₂, HCl, H₂O, CO₂ (структура + 3D, тип связи, полярность/диполь).
- §41 — 3D-ячейки 4 типов решёток.
- Хелпер `chem8Mol(mount, formula)` — обёртка над BIO для монтажа модели по формуле.
- Тест: модель строится, молярная масса совпадает с `Chem8.molarMass`.
## U5 — Обогащение контента §
По канве учебников Исаченковой (см. [[reference_textbook_sources]]):
- **8–10 задач** на § (сейчас 3–5): добавить уровни сложности, задачи «для любознательных».
- **life-grid** примеры из жизни в каждый § (где уместно).
- **insight-box** «это интересно» / историческая справка.
- **«Контрольные вопросы»** из учебника (адаптированные) — уже частично есть, расширить.
- Разобранные **примеры с пошаговым решением** (`exa-step`) в расчётных §.
## U6 — Финалы глав (интегрированные боссы)
Сейчас финал главы = шпаргалка + POOLS-задачи. Усилить:
- Каждый финал главы → **карта связей** (SVG-граф понятий главы).
- **Achievement-strip** «Мастер главы N» (+50 XP, confetti) при полном прохождении.
- Кнопка перехода к следующей главе.
## U7 — Синхронизация с админкой и доступом (Phase 8b)
- Проверить, что `chemistry-8` и 7 детей видны в админке (`/api/textbooks/admin/all`).
- Если добавлялись sim в `lab.html` → обновить `ADMIN_SIMS` в `admin.html` ([[feedback_sims_admin_sync]]).
- Доступ по классам/ученикам ([[project_content_access]], `/api/access`) — проверить выдачу.
- Прогресс/XP агрегируется в хабе (`/api/textbooks/chemistry-8/children`) — проверить.
## U8 — Качество
- jsdom-смоук на каждый новый виджет (монтаж + расчёт).
- Аудит баланса всех уравнений и KaTeX/`chemEq`-эскейпов.
- Полный прогон `cd backend && npm test`.
- Аудит доступности (контраст, фокус, клавиатура для боссов/тренажёров).
---
## Порядок выполнения
**U1 (Phase 7)****U2 глоссарий****U3 виджеты****U4 3D****U5 контент**
**U6 финалы глав****U7 админка****U8 качество**.
Темп: один U-блок = волна = commit + проходящие тесты + cherry-pick на `feature/chemistry-8`.
**Старт: U1 — финал курса в хабе.**