feat(chemistry-8): перестройка раздела intro под эталон учебников (SPA-движок)

По замечанию: учебник не соответствовал структуре/наполнению других учебников.
Перестроено по контракту глав физики (para-selector SPA + движок задач):

- chem8_engine.js — общий движок: para-selector, ленивая сборка §, makeCard,
  тренажёр задач (числовой ввод + MCQ, nav-dots, score), sidebar-шпаргалка с XP,
  уровни/достижения, серверная синхронизация прогресса, тема. Конфиг — CHEM8_CFG.
- chem8-textbook.css — фреймворк-CSS: layout+sidebar, hero, psel-карточки,
  para-hero (9 градиентов), карточки теории, def/remember/insight, тренажёр,
  mcq, флагман-карточки, виджеты, ach-popup (amber-палитра).
- chem8_intro_widgets.js — виджеты § (карта элементов, Mr, порция, Авогадро,
  M+объём) и флагманы (треугольник n–m–M, калькулятор газа, балансировщик,
  пошаговый решатель) на chem8_svg.js.
- chemistry_8_intro.html — перестроен: PARAS, build_p1..p9+pr1+final, POOLS
  (38 задач), SIDEBARS, TIPS. Богатая анатомия § как в физике.

Тесты: 23/23 (юнит + jsdom-виджеты + полностраничный jsdom SPA — para-selector,
активный §, монтаж виджетов, тренажёр, без ошибок скриптов). Ассеты отдаются 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-05-30 15:04:04 +03:00
parent fc1139f51d
commit 809d0316c3
6 changed files with 1334 additions and 748 deletions
+83
View File
@@ -0,0 +1,83 @@
'use strict';
/*
* Полностраничная jsdom-проверка chemistry_8_intro.html (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() {
let html = readF('frontend/textbooks/chemistry_8_intro.html');
const inl = {
'/js/biochem-core.js': readF('frontend/js/biochem-core.js'),
'/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'),
'/js/chem8_intro_widgets.js': readF('frontend/js/chem8_intro_widgets.js'),
'/js/chem8_engine.js': readF('frontend/js/chem8_engine.js')
};
// CDN katex → удалить; api/xp → стабы (LS отсутствует, renderMathInElement — no-op)
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() {
const errors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildPage(), {
runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/',
beforeParse(w) { w.scrollTo = function () {}; } // jsdom не реализует scrollTo (в браузере есть)
});
await wait(180); // дать отработать таймерам сборки § и монтажа виджетов (40–50 мс)
return { dom, errors, doc: dom.window.document };
}
test('страница SPA выполняется без ошибок скриптов', async () => {
const { errors } = await loadDom();
assert.deepEqual(errors, [], 'нет jsdomError: ' + errors.join(' | '));
});
test('para-selector построен (11 карточек) и первый § активен', async () => {
const { doc } = await loadDom();
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 11, '11 карточек §');
const active = doc.querySelector('.sec.active');
assert.ok(active && active.id === 'sec-p1', 'активен §1');
assert.ok(doc.querySelector('#p1-body .para-hero'), 'para-hero §1 построен');
});
test('виджеты § смонтированы движком', async () => {
const { doc } = await loadDom();
assert.ok(doc.querySelectorAll('#p1-el .el-cell').length > 10, 'карта элементов §1');
// перейдём на §6 и §8 через goTo, дождёмся монтажа флагманов
doc.defaultView.goTo('p6'); await wait(120);
assert.ok(doc.querySelector('#p6-mount .mtri'), 'треугольник §6');
doc.defaultView.goTo('p8'); await wait(120);
assert.ok(doc.querySelector('#p8-mount .ceqb'), 'балансировщик §8');
});
test('тренажёр задач отрисован для §2 (POOLS)', async () => {
const { doc } = await loadDom();
doc.defaultView.goTo('p2'); await wait(150);
assert.ok(doc.querySelector('#taskArea p2, #taskAreap2'), 'область задач §2');
assert.ok(doc.querySelectorAll('#navDotsp2 .nav-dot').length >= 4, 'навигация по задачам §2');
});
test('Chem8 доступен и считает Mr', async () => {
const { dom } = await loadDom();
assert.ok(dom.window.Chem8, 'window.Chem8 определён');
assert.equal(dom.window.Chem8.molarMass('CaCO3'), 100);
});
+25 -10
View File
@@ -101,27 +101,42 @@ test('хаб chemistry_8_hub.html существует и ссылается н
assert.ok(hub.includes('/api/textbooks/chemistry-8/children'), 'грузит детей');
});
test('каждая глава существует и задаёт свой _TB_SLUG', () => {
test('каждая глава существует, ссылается на хаб и подключает chem8', () => {
for (const ch of CHILDREN) {
const html = fs.readFileSync(path.join(TB, ch.file), 'utf8');
assert.ok(html.includes("const _TB_SLUG = '" + ch.slug + "'"), ch.file + ' slug');
assert.ok(html.includes('/textbook/chemistry-8"'), ch.file + ' ссылка назад в хаб');
assert.ok(html.includes('/js/chem8_svg.js'), ch.file + ' подключает chem8_svg');
assert.ok(html.includes('/js/biochem-core.js'), ch.file + ' подключает biochem-core');
if (ch.slug === 'chemistry-8-intro') {
// intro перестроен на движок (SPA): slug задаётся через CHEM8_CFG
assert.ok(html.includes("slug:'chemistry-8-intro'"), 'intro slug в CHEM8_CFG');
assert.ok(html.includes('/js/chem8_engine.js'), 'intro подключает движок');
} else {
assert.ok(html.includes("const _TB_SLUG = '" + ch.slug + "'"), ch.file + ' slug (каркас)');
}
}
});
test('Phase 1 — раздел intro наполнен (9 § + ПР1 + босс)', () => {
test('Phase 1 — раздел intro перестроен на движок (SPA, эталон)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_intro.html'), 'utf8');
for (let i = 1; i <= 9; i++) assert.ok(html.includes('id="p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="pr1"'), 'ПР1');
assert.ok(html.includes('id="boss"'), 'босс раздела');
assert.ok(html.includes('id="mt-mount"'), 'треугольник nmM');
assert.ok(html.includes('id="bal-mount"'), 'балансировщик');
assert.ok(html.includes("READ_IDS = ['p1','p2','p3','p4','p5','p6','p7','p8','p9']"), '9 читаемых § для прогресса');
assert.ok(html.includes('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('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
+306
View File
@@ -0,0 +1,306 @@
/* 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)}
/* 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}
+429
View File
@@ -0,0 +1,429 @@
/* 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);
setTimeout(function () { if (inp) 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);
+145
View File
@@ -0,0 +1,145 @@
/* 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();
}
W.CHEM8_WIDGETS = { p1: mount_p1, p2: mount_p2, p3: mount_p3, p4: mount_p4, p5: mount_p5, pr1: mount_pr1 };
W.FLAG_MOUNTS = { p6: mount_p6, p7: mount_p7, p8: mount_p8, p9: mount_p9 };
})(window);
File diff suppressed because it is too large Load Diff