/* 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); // preventScroll: иначе фокус прокручивает страницу к блоку задач (внизу §) setTimeout(function () { if (inp) { try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); } } }, 80); if (inp) inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') checkNum(sec); }); } } function selectMcq(sec, i) { var s = SEC[sec]; if (!s || s.answered) return; var q = POOLS()[sec][s.idx], ok = i === q.a; s.results[s.idx] = ok; s.selections[s.idx] = i; s.answered = true; if (ok) maybeAwardTask(sec); q.opts.forEach(function (_, j) { var btn = document.getElementById('mcqOpt' + sec + '_' + j); if (!btn) return; btn.disabled = true; if (j === q.a) btn.classList.add('mcq-cor'); else if (j === i && !ok) btn.classList.add('mcq-wrong'); }); var fb = document.getElementById('fb' + sec); fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail'); fb.innerHTML = ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: ' + 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);