809d0316c3
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> @
430 lines
27 KiB
JavaScript
430 lines
27 KiB
JavaScript
/* 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">Следующая →</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);
|