From bbfde0db51f6826e6a9e6c7059255fd1a4cfa9d0 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 15:04:04 +0300 Subject: [PATCH] =?UTF-8?q?@=20feat(chemistry-8):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=B5=D0=BB=D0=B0=20intro=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BB=D0=BE=D0=BD=20=D1=83=D1=87=D0=B5=D0=B1?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20(SPA-=D0=B4=D0=B2=D0=B8=D0=B6?= =?UTF-8?q?=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По замечанию: учебник не соответствовал структуре/наполнению других учебников. Перестроено по контракту глав физики (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) @ --- backend/tests/chemistry8-page.test.js | 83 ++ backend/tests/chemistry8.test.js | 35 +- frontend/css/chem8-textbook.css | 306 ++++++ frontend/js/chem8_engine.js | 429 ++++++++ frontend/js/chem8_intro_widgets.js | 145 +++ frontend/textbooks/chemistry_8_intro.html | 1084 +++++++-------------- 6 files changed, 1334 insertions(+), 748 deletions(-) create mode 100644 backend/tests/chemistry8-page.test.js create mode 100644 frontend/css/chem8-textbook.css create mode 100644 frontend/js/chem8_engine.js create mode 100644 frontend/js/chem8_intro_widgets.js diff --git a/backend/tests/chemistry8-page.test.js b/backend/tests/chemistry8-page.test.js new file mode 100644 index 0000000..c92b342 --- /dev/null +++ b/backend/tests/chemistry8-page.test.js @@ -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(/') + .replace(/'); + }); + 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); +}); diff --git a/backend/tests/chemistry8.test.js b/backend/tests/chemistry8.test.js index f9fd2bf..52fb399 100644 --- a/backend/tests/chemistry8.test.js +++ b/backend/tests/chemistry8.test.js @@ -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"'), 'треугольник n–m–M'); - assert.ok(html.includes('id="bal-mount"'), 'балансировщик'); - assert.ok(html.includes("READ_IDS = ['p1','p2','p3','p4','p5','p6','p7','p8','p9']"), '9 читаемых § для прогресса'); + assert.ok(html.includes('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 diff --git a/frontend/css/chem8-textbook.css b/frontend/css/chem8-textbook.css new file mode 100644 index 0000000..a34e6d0 --- /dev/null +++ b/frontend/css/chem8-textbook.css @@ -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} diff --git a/frontend/js/chem8_engine.js b/frontend/js/chem8_engine.js new file mode 100644 index 0000000..c49a062 --- /dev/null +++ b/frontend/js/chem8_engine.js @@ -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(), ... } // наполняют #-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 = '
' + p.num + '
' + p.name + '
' + + (p.sub ? '
' + p.sub + '
' : '') + + '
' + + ''; + 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 = ' Ур. ' + 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 '
' + + '
Задачи параграфа' + + '0 верно' + + '0/?' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '
Параграф пройден!
' + + '
'; + } + 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 = '
XP-прогрессУр. ' + STATE.level + '
' + + '
' + + '
' + STATE.xp + ' XP' + xpNext + ' XP
'; + html += '

' + sb.title + '

'; + sb.rows.forEach(function (r) { html += '
' + r[0] + '' + (r[1] ? ' — ' + r[1] : '') + '
'; }); + html += '
'; + var tips = W.TIPS || []; var tip = tips.filter(function (t) { return t.sec === id; })[0] || tips[0]; + if (tip) html += '

Подсказка

' + tip.html + '
'; + if (STATE.achievements.size > 0) { + html += '

Достижения ' + STATE.achievements.size + '

'; + var vals = []; STATE.achievements.forEach(function (v) { vals.push(v); }); + vals.slice(-4).forEach(function (t) { html += '
✓ ' + t + '
'; }); + html += '
'; + } + box.innerHTML = html; + if (W.renderMathInElement) try { renderMath(box); } catch (e) {} + } + + /* ── карточки / навигация / кнопка прочтения ───────────────────── */ + var ICONS = { + theory: '', + example: '', + rule: '', + lab: '' + }; + function makeCard(kind, title, num, body) { + var labels = { theory: 'Теория', example: 'Пример', rule: 'Правило', lab: 'Практика' }; + return '
' + (ICONS[kind] || ICONS.theory) + '
' + + '
' + (labels[kind] || '') + (title && title !== labels[kind] ? ' \xb7 ' + title : '') + '
' + + (num ? '
' + num + '
' : '') + '
' + body + '
'; + } + 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 = '
'; + h += prev ? '' : ''; + h += next ? '' : ''; + return h + '
'; + } + function readButton(paraId) { + var p = PARAS().filter(function (x) { return x.id === paraId; })[0]; + var tail = p && p.final ? 'финал' : (p ? p.num : '?'); + return '
'; + } + 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 = '
Задача ' + (s.idx + 1) + ' из ' + pool.length + ' · Тест
' + + '
' + q.q + '
' + + 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 ''; + }).join('') + '
'; + } else { + area.innerHTML = '
Задача ' + (s.idx + 1) + ' из ' + pool.length + '
' + + '
' + q.q + '
' + + (q.hint ? '
' + q.hint + '
' : '') + + '
' + + '' + (q.unit || '') + '' + + (done ? '' : '') + '
'; + } + if (done) { + var ok = s.results[s.idx]; + fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail'); + fb.innerHTML = isMcq + ? (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: ' + q.opts[q.a] + '. ' + (q.ex || '')) + : (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: ' + q.a + ' ' + (q.unit || '') + '. ' + (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 || '') : 'Неверно. Правильный ответ: ' + q.opts[q.a] + '. ' + (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 || '') : 'Неверно. Правильный ответ: ' + q.a + ' ' + (q.unit || '') + '. ' + (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 ''; + }).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); diff --git a/frontend/js/chem8_intro_widgets.js b/frontend/js/chem8_intro_widgets.js new file mode 100644 index 0000000..04b61f4 --- /dev/null +++ b/frontend/js/chem8_intro_widgets.js @@ -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 = '' + EL[s][0] + '' + s + '' + ar + ''; + c.addEventListener('click', function () { + grid.querySelectorAll('.el-cell').forEach(function (x) { x.classList.remove('on'); }); c.classList.add('on'); + info.innerHTML = '' + EL[s][1] + ' (' + 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 = 'M_r(' + f + ') = ' + C().fmt(mr) + '
' + + Object.keys(cnt).map(function (e) { return e + ': A_r=' + (C().arOf ? C().arOf(e) : '?') + ' × ' + cnt[e]; }).join('  |  ') + + '
Σ = ' + Object.keys(cnt).map(function (e) { return (C().arOf ? C().arOf(e) : '?') + '·' + cnt[e]; }).join(' + ') + ' = ' + C().fmt(mr) + '
'; + } + 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 = 'n = ' + n.toFixed(1).replace('.', ',') + ' моль
m = n·M = ' + n.toFixed(1).replace('.', ',') + ' · ' + String(M[s]).replace('.', ',') + ' = ' + rr(m, 1) + ' г
N = n·N_A = ' + rr(N, 2) + '·10²³ частиц
'; + } + 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 = 'N = n · N_A = ' + n.toFixed(2).replace('.', ',') + ' · 6,02·10²³ = ' + rr(N, 2) + '·10²³ частиц'; } + 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 = 'M(' + f + ') = ' + C().fmt(mr) + ' г/моль
1 моль газа при н.у. → 22,4 л
Плотность газа ≈ M/22,4 = ' + rr(mr / 22.4) + ' г/л
'; + } + 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 = 'M(' + f + ')=' + M + ' г/моль
n = ' + rr(n) + ' моль
m = ' + rr(m) + ' г
V(н.у.) = ' + rr(V) + ' л
N = ' + rr(N) + '·10²³ частиц
'; + } + 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 = '' + p.eq + '
' + p.given + '
'; + for (var i = 0; i < shown; i++) html += '
' + p.steps[i] + '
'; + if (shown === 0) html += 'Нажмите «Следующий шаг», чтобы решать пошагово.'; + html += '
'; 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); diff --git a/frontend/textbooks/chemistry_8_intro.html b/frontend/textbooks/chemistry_8_intro.html index 671c02b..c73d111 100644 --- a/frontend/textbooks/chemistry_8_intro.html +++ b/frontend/textbooks/chemistry_8_intro.html @@ -9,773 +9,381 @@ Химия 8 · Вводный раздел · «Количественные понятия в химии» + - + - + +
-
- - - К разделам - +
-
Вводный раздел · § 1–9 · ПР 1
-

Количественные понятия в химии

+

Химия 8 · Вводный раздел

+
Количественные понятия: атомы, формулы, моль, молярная масса и объём, расчёты по уравнениям
- + К разделам +
-
-
-
n
-
-
Прогресс раздела
-
0 из 9 параграфов · 0%
-
-
-
0 XP
+
+
+ +
+

Химия начинается со счёта

+

Прежде чем изучать вещества и реакции, химик учится их «считать»: переходить от массы к числу частиц, от объёма газа — к количеству вещества, рассчитывать продукты реакции по уравнению. Эти количественные понятия — фундамент всего курса.

+
+ +
+ Прогресс раздела +
+ 0% +
+
+
+
+ +
+
Параграфы раздела
+
+
+ +
§ 1

Атомы. Химические элементы. Относительная атомная масса

+
§ 2

Молекулы. Простые и сложные вещества. Формулы. $M_r$

+
§ 3

Химическое количество вещества

+
§ 4

Моль. Постоянная Авогадро

+
§ 5

Молярная масса. Молярный объём газов

+
§ 6

Вычисление $n$ по массе и массы по $n$

+
§ 7

Вычисление количества и объёма газа

+
ПР 1

Практическая работа: химическое количество вещества

+
§ 8

Химические реакции

+
§ 9

Количественные расчёты по уравнениям реакций

+

Финал раздела

+
-
+ + -
- - -
- -
-
§ 1

Атомы. Химические элементы. Относительная атомная масса

$A_r(\text{O}) = 16$
-
-

теория Атом и химический элемент

-

Атом — мельчайшая химически неделимая частица вещества. Химический элемент — вид атомов с одинаковым зарядом ядра. Каждый элемент имеет символ (например, H, O, Fe) и порядковый номер $Z$ в периодической системе.

-
Относительная атомная масса $A_r$ показывает, во сколько раз масса атома больше $\tfrac{1}{12}$ массы атома углерода-12. Величина безразмерная: $A_r(\text{H})=1$, $A_r(\text{O})=16$, $A_r(\text{Fe})=56$.
-
-
-
Карта элементов: клик → $Z$, название, $A_r$
-
-
Выберите элемент, чтобы увидеть его характеристики.
-
-
-
Во сколько раз атом серы ($A_r=32$) тяжелее атома кислорода ($A_r=16$)?
-
-
-
-
Изучено
-
- -
-
§ 2

Молекулы. Простые и сложные вещества. Химические формулы. $M_r$

$M_r=\sum A_r$
-
-

теория Вещества и формулы

-

Простое вещество образовано атомами одного элемента ($\text{O}_2$, $\text{Fe}$), сложное — разных ($\text{H}_2\text{O}$, $\text{CaCO}_3$). Химическая формула показывает качественный и количественный состав: индекс — число атомов элемента.

-
Относительная молекулярная масса $M_r$ равна сумме относительных атомных масс всех атомов в формуле. Например, $M_r(\text{H}_2\text{O}) = 2\cdot1 + 16 = 18$.
-
-
-
Калькулятор $M_r$ по формуле
-
- - - -
-
- - - - -
-
Введите формулу и нажмите «Вычислить».
-
-
Изучено
-
- -
-
§ 3

Химическое количество вещества

$n$, моль
-
-

теория Порция вещества

-

Считать атомы и молекулы поштучно невозможно — их слишком много. Поэтому ввели специальную «порцию» — химическое количество вещества $n$, единица — моль. Одна и та же порция ($1$ моль) любого вещества содержит одинаковое число частиц.

-
Химическое количество $n$ связывает массу $m$, число частиц $N$ и объём газа $V$. Это «мост» между миром атомов и граммами на весах.
-
-
-
Порция вещества: $n \Rightarrow N$ и $m$
-
- - - - - 1,0 -
-
-
-
Изучено
-
- -
-
§ 4

Моль — единица химического количества. Постоянная Авогадро

$N = n\cdot N_A$
-
-

теория Постоянная Авогадро

-
1 моль — это химическое количество вещества, содержащее столько же частиц, сколько атомов в $12$ г углерода-$12$, а именно $N_A = 6{,}02\cdot10^{23}$ частиц/моль — постоянная Авогадро.
-

Число частиц: $N = n\cdot N_A$. Отсюда $n = \dfrac{N}{N_A}$.

-
-
-
Счётчик частиц $N = n\cdot N_A$
-
2,0
-
-
-
-
Сколько молекул содержится в $0{,}5$ моль воды? ($N_A=6{,}02\cdot10^{23}$)
-
-
-
-
Изучено
-
- -
-
§ 5

Молярная масса. Молярный объём газов

$V_m=22{,}4$ л/моль
-
-

теория M и Vm

-
Молярная масса $M$ — масса $1$ моль вещества (г/моль). Численно $M$ равна $M_r$: $M(\text{H}_2\text{O})=18$ г/моль.
-
Молярный объём $V_m$ — объём $1$ моль газа. При нормальных условиях (н.у.) $V_m = 22{,}4$ л/моль для любого газа (закон Авогадро).
-
-
-
M по формуле и объём 1 моль газа
-
-
M(CO₂) и объём при н.у. появятся здесь.
-
-
Изучено
-
- -
-
§ 6 · звёздный виджет

Вычисление $n$ по массе и массы по $n$

$n = \dfrac{m}{M}$
-
-

правило Треугольник n–m–M

-

Три величины связаны формулой $m = n\cdot M$. Закрой искомую — получишь формулу: $n=\dfrac{m}{M}$, $m=n\cdot M$, $M=\dfrac{m}{n}$.

-
-
Дано: $m=36$ г воды, $M=18$ г/моль.
-
$n = \dfrac{m}{M} = \dfrac{36}{18} = 2$ моль.
-
-
-
-
Интерактивный треугольник n–m–M
-
-
-
-
Изучено
-
- -
-
§ 7

Вычисление $n$ газа по объёму и объёма по $n$

$n = \dfrac{V}{V_m}$
-
-

правило Связка m – n – V – N

-

Для газа при н.у.: $n=\dfrac{V}{V_m}$, $V=n\cdot V_m$ ($V_m=22{,}4$ л/моль). Вместе с $n=\dfrac{m}{M}$ и $N=n\cdot N_A$ это единая система: зная одно — найдёшь всё.

-
-
-
Универсальный калькулятор газа
-
-
- - - -
-
-
-
Изучено
-
- -
-
Практическая работа 1

Химическое количество вещества

$n=\dfrac{m}{M}$
-
-

практика Порядок работы

-
    -
  • Взвесь на весах образцы веществ (например, $\text{NaCl}$, $\text{CuSO}_4$).
  • -
  • Запиши массу $m$ и определи молярную массу $M$ по формуле.
  • -
  • Вычисли химическое количество $n=\dfrac{m}{M}$ и число частиц $N=n\cdot N_A$.
  • -
  • Оформи вывод: какому числу частиц соответствует взятая масса.
  • -
-
Работай аккуратно с реактивами и весами; не пробуй вещества на вкус.
-
-
Выполнено
-
- -
-
§ 8 · звёздный виджет

Химические реакции

закон сохранения массы
-
-

теория Уравнение реакции

-

В химической реакции одни вещества превращаются в другие, но атомы не исчезают и не появляются (закон сохранения массы М. В. Ломоносова, А. Лавуазье). Поэтому уравнение реакции уравнивают коэффициентами — число атомов каждого элемента слева и справа равно.

-

Типы реакций: соединения ($A+B\to AB$), разложения ($AB\to A+B$), замещения ($A+BC\to AC+B$), обмена ($AB+CD\to AD+CB$).

-
-
-
Балансировщик: расставь коэффициенты
-
- -
-
-
-
Изучено
-
- -
-
§ 9 · звёздный виджет

Количественные расчёты по уравнениям реакций

по мольным отношениям
-
-

правило Алгоритм расчёта

-
    -
  • Записать и уравнять уравнение реакции.
  • -
  • Найти $n$ известного вещества: $n=\dfrac{m}{M}$ (или $\dfrac{V}{V_m}$).
  • -
  • По коэффициентам найти $n$ искомого (мольное отношение).
  • -
  • Перейти к массе/объёму: $m=n\cdot M$ ($V=n\cdot V_m$).
  • -
-
-
-
Пошаговый решатель по уравнению
-
-
-
-
-
Изучено
-
- -
-
-
Босс раздела: количественные понятия
-
4 задачи на всё, что изучено. За каждую — +10 XP. Победишь всех — ачивка «Счёт в химии» и +30 XP.
-
Решено: 0 / 4
-
-
- - Вводный раздел пройден! Ачивка «Счёт в химии» получена. - К разделам → -
-
-
- -
-
- - -
Интерактивный учебник «Химия — 8 класс» · Вводный раздел · LearnSpace
+
Интерактивный учебник «Химия — 8 класс» · Вводный раздел · «Количественные понятия в химии» · LearnSpace
+
Достижение!