Files
Maxim Dolgolyov 8edab2196f feat(math6): stepPlayer — все «Разборы по шагам» стали интерактивными
Math6Anim.stepPlayer (DOM): пошаговый плеер с кнопками Назад/Дальше/Авто
и точками прогресса, рендерит KaTeX по шагам. Math6Anim.stepifyExamples
сканирует секцию и превращает карточки «Разбор по шагам» (<ol> в теле) в
такой плеер. Движок зовёт stepifyExamples в goTo (guarded) → автоматически
во ВСЕХ главах и параграфах, включая простые работы с дробями/столбиком.
Подключён math6_anim в Гл.2,3 (теперь во всех 6). Тесты math6: 20/20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:44:34 +03:00

396 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* math6_engine.js — общий движок-плумбинг интерактивного учебника «Математика 6».
*
* Каждая страница-глава объявляет конфиг `window.M6` и (опционально) функции-билдеры
* buildXX(), затем подключает этот файл. Движок строит para-selector, навигацию,
* прогресс/XP/достижения, сайдбар (шпаргалка + подсказка), поиск, глоссарий, тему.
*
* § без билдера автоматически получает заглушку (.m6-placeholder + кнопка прочтения),
* поэтому каркас новой главы = только M6.paras. Кастомные интерактивы § пишутся
* inline-билдерами на странице и пользуются глобальными хелперами этого движка:
* makeCard, secNav, readBtn, feedback, renderMath, fmt, num, addXp, bumpProgress,
* achievement, setupSorter, confetti, goTo, M6icon.
*
* Конфиг window.M6 = {
* slug, lsPrefix, xpKey, paras:[{id,num,name,sub,final?,applied?}],
* achLabels:{}, startAch:[id,text], finalAch:[id,text],
* sidebars:{id:{title,rows:[[k,v]]}}, tips:[{sec,html}],
* glossary:[{term,def,sec,aliases:[]}], searchRows:[[kind,title,desc,sec]],
* builders:{id:fn}, footer
* }
*/
(function () {
'use strict';
if (window.__M6_ENGINE) return;
window.__M6_ENGINE = true;
var M6 = window.M6 || (window.M6 = {});
var LSPRE = function () { return M6.lsPrefix || 'math6'; };
var XPKEY = function () { return M6.xpKey || 'math6_xp'; };
/* ============================================================ STATE */
var STATE = { current: null, progress: {}, achievements: new Map(), xp: 0, level: 1 };
window.M6STATE = STATE;
function paras() { return M6.paras || []; }
function total() { return paras().length || 1; }
function calcLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
function _xpForLevel(lv) { return (lv - 1) * (lv - 1) * 100; }
function loadProgress() {
paras().forEach(function (p) { if (STATE.progress[p.id] == null) STATE.progress[p.id] = 0; });
try {
var s = localStorage.getItem(LSPRE() + '_progress');
if (s) Object.assign(STATE.progress, JSON.parse(s));
var a = localStorage.getItem(LSPRE() + '_achievements');
if (a) {
var p = JSON.parse(a);
if (Array.isArray(p)) p.forEach(function (id) { STATE.achievements.set(id, achLabel(id)); });
else if (p && typeof p === 'object') Object.keys(p).forEach(function (id) { STATE.achievements.set(id, (p[id] && p[id] !== id) ? p[id] : achLabel(id)); });
}
STATE.xp = +(localStorage.getItem(XPKEY()) || 0);
STATE.level = calcLevel(STATE.xp);
} catch (e) {}
}
function saveProgress() {
try {
localStorage.setItem(LSPRE() + '_progress', JSON.stringify(STATE.progress));
localStorage.setItem(LSPRE() + '_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
localStorage.setItem(XPKEY(), String(STATE.xp));
} catch (e) {}
}
function achLabel(id) { return (M6.achLabels && M6.achLabels[id]) || id; }
function bumpProgress(key, delta) {
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key] || 0) + delta));
saveProgress(); refreshProgressUI();
if (STATE.progress[key] >= 50) markParaRead(key);
if (STATE.progress[key] >= 100) {
achievement(key + '_done');
var fin = paras().filter(function (p) { return p.final; }).map(function (p) { return p.id; });
if (fin.indexOf(key) >= 0 && M6.finalAch) achievement(M6.finalAch[0], M6.finalAch[1]);
}
}
/* ====================================================== SERVER SYNC */
var _markedRead = new Set();
var _pendingProgressBody = null, _progressTimer = null;
function _flushProgress() {
var body = _pendingProgressBody; _pendingProgressBody = null; if (!body) return;
var tok = (window.LS && LS.getToken) ? LS.getToken() : ''; if (!tok) return;
fetch('/api/textbooks/' + M6.slug + '/progress', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok }, body: JSON.stringify(body), keepalive: true }).catch(function () {});
}
function _queueProgress(patch) {
_pendingProgressBody = Object.assign(_pendingProgressBody || {}, patch);
if (_progressTimer) clearTimeout(_progressTimer);
_progressTimer = setTimeout(_flushProgress, 600);
}
function markLastPara(id) { _queueProgress({ last_para: id }); }
function markParaRead(id) { if (_markedRead.has(id)) return; _markedRead.add(id); _queueProgress({ mark_read: id }); }
window.addEventListener('beforeunload', _flushProgress);
function loadServerReadState() {
var tok = (window.LS && LS.getToken) ? LS.getToken() : ''; if (!tok) return;
fetch('/api/textbooks/' + M6.slug, { headers: { 'Authorization': 'Bearer ' + tok } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.progress) return;
(d.progress.read || []).forEach(function (k) { _markedRead.add(k); if ((STATE.progress[k] || 0) < 50) STATE.progress[k] = 100; });
saveProgress(); refreshProgressUI();
}).catch(function () {});
}
/* ============================================================ XP */
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(); refreshProgressUI();
if (window.LS && window.LS.xp) window.LS.xp.add(n, (M6.slug || 'math6') + '-' + (src || 'misc'));
if (STATE.level > prev) {
popup('Уровень ' + STATE.level + '!');
if (window.confetti) try { confetti(); } catch (e) {}
}
}
function refreshProgressUI() {
var sum = 0; paras().forEach(function (p) { sum += (STATE.progress[p.id] || 0); });
var tot = Math.round(sum / total());
var f = document.getElementById('hero-hp-fill'); if (f) f.style.width = tot + '%';
var t = document.getElementById('hero-hp-text'); if (t) t.textContent = tot + '% пройдено';
document.querySelectorAll('[data-prog-card]').forEach(function (el) {
var k = el.dataset.progCard; var fl = el.querySelector('.psel-prog-fill'); if (fl) fl.style.width = (STATE.progress[k] || 0) + '%';
});
var xpBadge = document.getElementById('hero-xp-badge');
if (xpBadge) xpBadge.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 + ' · ' + (STATE.xp || 0) + ' XP';
if (STATE.current && document.getElementById('sidebar-content')) { try { buildSidebar(STATE.current); } catch (e) {} }
}
function popup(text, gold) {
var pop = document.getElementById('ach-popup'); if (!pop) return;
document.getElementById('ach-text').textContent = text;
pop.classList.add('show'); setTimeout(function () { pop.classList.remove('show'); }, gold ? 3300 : 2600);
}
function achievement(id, text) {
if (STATE.achievements.has(id)) return;
if (!text && !(M6.achLabels && M6.achLabels[id])) return; /* неизвестные id игнорируем */
STATE.achievements.set(id, text || achLabel(id));
saveProgress(); popup(text || achLabel(id), true); addXp(20, 'ach-' + id);
}
/* ================================================ SECTIONS */
function buildSections() {
var host = document.getElementById('sections'); if (!host) return; /* статичные секции уже в HTML */
if (host.dataset.built) return; host.dataset.built = '1';
var html = '';
paras().forEach(function (p) {
var wm = p.wm || (p.final ? '★' : (M6.wm || ''));
var numCls = p.final ? ' style="background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri))"' : '';
html += '<section id="sec-' + p.id + '" class="sec" data-watermark="' + wm + '">'
+ '<div class="sec-header"><span class="sec-num"' + numCls + '>' + p.num + '</span><h2 class="sec-h">' + p.name + '</h2></div>'
+ '<div id="' + p.id + '-body"></div></section>';
});
host.innerHTML = html;
}
/* ================================================ PARA SELECTOR/NAV */
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' : '') + (p.applied ? ' applied' : '');
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><div class="psel-prog"><div class="psel-prog-fill"></div></div>';
card.addEventListener('click', function () { goTo(p.id); });
g.appendChild(card);
});
}
var BUILT = new Set();
function ensureBuilt(id) {
if (BUILT.has(id)) return;
BUILT.add(id);
var cfg = window.M6 || M6; /* читаем актуальный конфиг из window */
var fn = cfg.builders && cfg.builders[id];
if (fn) { try { fn(); } catch (e) { placeholder(id); } }
else placeholder(id);
}
function placeholder(id) {
var box = document.getElementById(id + '-body'); if (!box) return;
box.innerHTML = '<div class="m6-placeholder"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 6.5v11"/><path d="M5 8a3 3 0 0 1 3-3h0a3 3 0 0 1 3 3v9"/><path d="M19 8a3 3 0 0 0-3-3h0a3 3 0 0 0-3 3v9"/></svg><div>Содержание этого параграфа готовится.</div></div>'
+ readBtn(id);
}
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);
window.scrollTo({ top: 0, behavior: 'smooth' });
if ((STATE.progress[id] || 0) < 10) bumpProgress(id, 10);
if (el && window.Math6Anim && window.Math6Anim.stepifyExamples) { try { window.Math6Anim.stepifyExamples(el); } catch (e) {} }
if (el && window.renderMathInElement) setTimeout(function () { renderMath(el); }, 0);
setTimeout(function () { try { wrapGlossary(el); } catch (e) {} }, 60);
markLastPara(id);
if (window.innerWidth <= 980) { var side = document.getElementById('col-side'), bk = document.getElementById('col-side-backdrop'); if (side) side.classList.remove('open'); if (bk) bk.classList.remove('show'); }
}
/* ============================================================ SIDEBAR */
function buildSidebar(id) {
var box = document.getElementById('sidebar-content'); if (!box) return;
var SB = M6.sidebars || {};
var sb = SB[id] || SB[Object.keys(SB)[0]] || { title: 'Шпаргалка', rows: [] };
var html = '';
var xpForLv = _xpForLevel(STATE.level), xpNext = _xpForLevel(STATE.level + 1);
var xpInLv = STATE.xp - xpForLv, xpRange = xpNext - xpForLv;
var xpPct = xpRange > 0 ? Math.round(xpInLv / xpRange * 100) : 100;
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:' + xpPct + '%"></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 = M6.tips || [];
var tip = tips.filter(function (t) { return t.sec === id; })[0] || tips[0];
if (tip) html += '<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)"><h4 style="color:#92400e;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polygon points="12,2 22,20 2,20"/></svg>Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;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>';
Array.from(STATE.achievements.values()).slice(-5).forEach(function (text) { html += '<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ ' + text + '</div>'; });
html += '</div>';
}
box.innerHTML = html;
if (window.renderMathInElement) try { renderMath(box); } catch (e) {}
}
/* ============================================================ THEME */
function initTheme() {
var key = LSPRE() + '_theme';
var t = localStorage.getItem(key) || 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 dark = document.documentElement.classList.contains('dark');
localStorage.setItem(key, dark ? 'dark' : 'light'); localStorage.setItem('theme', dark ? 'dark' : 'light');
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
});
}
/* ============================================================ HELPERS */
function renderMath(root) {
if (root && window.renderMathInElement) {
try { renderMathInElement(root, { delimiters: [{ left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\[', right: '\\]', display: true }, { left: '\\(', right: '\\)', display: false }], throwOnError: false }); } catch (e) {}
}
}
function feedback(elm, ok, text) { if (!elm) return; elm.className = 'feedback ' + (ok ? 'ok' : 'fail'); elm.innerHTML = text || (ok ? '✓ Верно!' : '✗ Неверно'); elm.style.display = 'block'; try { renderMath(elm); } catch (e) {} }
function num(n) { if (!isFinite(n)) return '?'; if (Number.isInteger(n)) return String(n); return (Math.round(n * 1e9) / 1e9).toString().replace('.', ','); }
function fmt(n) { return num(n); }
var ICONS = {
repeat: '<svg class="ic" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
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>',
algo: '<svg class="ic" viewBox="0 0 24 24"><polyline points="17 11 21 7 17 3"/><line x1="21" y1="7" x2="9" y2="7"/><polyline points="7 13 3 17 7 21"/><line x1="3" y1="17" x2="15" y2="17"/></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>',
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>',
oral: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
};
function M6icon(k) { return ICONS[k] || ''; }
function makeCard(kind, title, n, body) {
var labels = { repeat: 'Повторение', theory: 'Теория', algo: 'Алгоритм', rule: 'Правило', example: 'Пример', oral: 'Устно' };
return '<div class="card"><div class="card-header"><div class="card-icon ' + kind + '">' + (ICONS[kind] || '') + '</div><div class="card-title">' + (labels[kind] || '') + (title && title !== labels[kind] ? ' · ' + title : '') + '</div>' + (n ? '<div class="card-num">' + n + '</div>' : '') + '</div><div class="card-body">' + body + '</div></div>';
}
function shortName(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> ' + shortName(prev) + '</button>' : '<span></span>';
h += next ? '<button class="btn primary" onclick="goTo(\'' + next + '\')">' + shortName(next) + ' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>' : '<span></span>';
h += '</div>'; return h;
}
function readBtn(id, label) {
return '<div class="read-wrap"><button class="btn primary" data-read="' + id + '"><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> ' + (label || ('Я прочитал ' + shortName(id) + ' (+10 XP)')) + '</button></div>';
}
document.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('[data-read]'); if (!b) return;
var id = b.getAttribute('data-read');
addXp(10, id + '-read'); bumpProgress(id, 30);
b.textContent = 'Прочитано! +10 XP'; b.disabled = true; b.style.opacity = .6;
});
/* ============================================================ CONFETTI */
var _cc = null, _cp = [], _craf = null;
function confetti() {
try {
if (/jsdom/i.test(navigator.userAgent || '')) return; /* headless-guard: canvas в jsdom не реализован */
if (!_cc) { _cc = document.createElement('canvas'); _cc.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:9999'; document.body.appendChild(_cc); }
var c = _cc; c.width = window.innerWidth; c.height = window.innerHeight;
var ctx = c.getContext('2d'); if (!ctx) return;
var colors = ['#4f46e5', '#6366f1', '#0891b2', '#10b981', '#f59e0b', '#e11d48'];
for (var i = 0; i < 80; i++) _cp.push({ x: window.innerWidth / 2 + (Math.random() - .5) * 200, y: window.innerHeight / 2, vx: (Math.random() - .5) * 14, vy: -10 - Math.random() * 10, g: .4, life: 100, color: colors[i % colors.length], r: 4 + Math.random() * 4, rot: 0, vRot: (Math.random() - .5) * .3 });
if (_craf) cancelAnimationFrame(_craf);
function frame() { ctx.clearRect(0, 0, c.width, c.height); _cp = _cp.filter(function (p) { p.x += p.vx; p.y += p.vy; p.vy += p.g; p.life--; p.rot += p.vRot; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color; ctx.fillRect(-p.r, -p.r / 2, p.r * 2, p.r); ctx.restore(); return p.life > 0 && p.y < c.height + 50; }); if (_cp.length > 0) _craf = requestAnimationFrame(frame); else { ctx.clearRect(0, 0, c.width, c.height); _craf = null; } }
frame();
} catch (e) {}
}
/* ============================================================ SORTER (DnD) */
function setupSorter(cfg) {
var placed = {}; var pool = document.getElementById(cfg.poolId); var scope = document.querySelector(cfg.scopeSelector);
if (!pool || !scope) return { placed: placed, render: function () {}, reset: function () {} };
pool.classList.add('dnd-pool'); if (cfg.columnLayout) pool.classList.add('col');
var armed = null;
function buildChip(it, isPlaced) { var e = document.createElement('div'); e.className = 'dnd-chip' + (isPlaced ? ' placed' : ''); e.dataset.id = it.id; e.innerHTML = '<span class="dnd-txt">' + it.html + '</span><span class="dnd-x" title="Убрать">×</span>'; attach(e, it.id); return e; }
function attach(elm, itId) { elm.addEventListener('pointerdown', function (ev) { if (ev.button !== undefined && ev.button !== 0) return; ev.preventDefault(); if (ev.target.classList && ev.target.classList.contains('dnd-x')) { ev.stopPropagation(); if (placed[itId]) { delete placed[itId]; render(); } else if (armed === itId) { armed = null; render(); } return; } var sx = ev.clientX, sy = ev.clientY; var r = elm.getBoundingClientRect(); var ox = ev.clientX - r.left, oy = ev.clientY - r.top; var ghost = null, dragging = false; try { elm.setPointerCapture(ev.pointerId); } catch (e) {} function onMove(e2) { var dx = e2.clientX - sx, dy = e2.clientY - sy; if (!dragging && Math.hypot(dx, dy) > 8) { dragging = true; ghost = elm.cloneNode(true); ghost.style.cssText = 'position:fixed;z-index:9999;pointer-events:none;opacity:.9;transform:rotate(-2.5deg);box-shadow:0 14px 36px rgba(0,0,0,.32);width:' + r.width + 'px;left:' + (e2.clientX - ox) + 'px;top:' + (e2.clientY - oy) + 'px'; document.body.appendChild(ghost); elm.classList.add('dragging'); } if (dragging && ghost) { ghost.style.left = (e2.clientX - ox) + 'px'; ghost.style.top = (e2.clientY - oy) + 'px'; var under = document.elementsFromPoint(e2.clientX, e2.clientY); scope.querySelectorAll('.drop-box.over,.dnd-pool.over').forEach(function (n) { n.classList.remove('over'); }); var tgt = under.find(function (n) { return n.classList && (n.classList.contains('drop-box') || n.classList.contains('dnd-pool')); }); if (tgt) tgt.classList.add('over'); } } function onUp(e2) { elm.removeEventListener('pointermove', onMove); elm.removeEventListener('pointerup', onUp); elm.removeEventListener('pointercancel', onUp); elm.classList.remove('dragging'); if (ghost) { ghost.remove(); ghost = null; } scope.querySelectorAll('.drop-box.over,.dnd-pool.over').forEach(function (n) { n.classList.remove('over'); }); if (dragging) { var under = document.elementsFromPoint(e2.clientX, e2.clientY); var box = under.find(function (n) { return n.classList && n.classList.contains('drop-box'); }); var pl = under.find(function (n) { return n.classList && n.classList.contains('dnd-pool'); }); if (box) { var di = box.querySelector('[data-cat]'); if (di) { placed[itId] = di.dataset.cat; armed = null; render(); return; } } else if (pl) { delete placed[itId]; armed = null; render(); return; } } else { if (placed[itId]) { delete placed[itId]; armed = null; render(); } else { armed = (armed === itId) ? null : itId; render(); } } dragging = false; } elm.addEventListener('pointermove', onMove); elm.addEventListener('pointerup', onUp); elm.addEventListener('pointercancel', onUp); }); }
function attachBoxTaps() { scope.querySelectorAll('.drop-box').forEach(function (box) { box.addEventListener('click', function (ev) { if (!armed) return; if (ev.target.closest('.dnd-chip')) return; var di = box.querySelector('[data-cat]'); if (di) { placed[armed] = di.dataset.cat; armed = null; render(); } }); }); }
function render() { pool.innerHTML = ''; cfg.items.forEach(function (it) { if (placed[it.id]) return; var c = buildChip(it, false); if (armed === it.id) c.classList.add('armed'); pool.appendChild(c); }); cfg.cats.forEach(function (cat) { var box = scope.querySelector('.drop-items[data-cat="' + cat + '"]'); if (!box) return; box.innerHTML = ''; cfg.items.forEach(function (it) { if (placed[it.id] !== cat) return; box.appendChild(buildChip(it, true)); }); }); if (window.renderMathInElement) try { renderMath(scope); } catch (e) {} }
attachBoxTaps(); render();
return { placed: placed, render: render, reset: function () { for (var k in placed) delete placed[k]; armed = null; render(); } };
}
/* ============================================================ GLOSSARY */
function wrapGlossary(root) {
var GL = M6.glossary || []; if (!root || root.__glossDone || !GL.length) return;
var allAliases = [];
GL.forEach(function (g, i) { (g.aliases || [g.term]).forEach(function (a) { allAliases.push({ a: a, i: i }); }); });
allAliases.sort(function (x, y) { return y.a.length - x.a.length; });
var re = new RegExp('(?<![\\w\\u0400-\\u04ff-])(' + allAliases.map(function (x) { return x.a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') + ')(?![\\w\\u0400-\\u04ff-])', 'iu');
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: function (node) { var p = node.parentElement; if (!p) return NodeFilter.FILTER_REJECT; if (p.closest('.katex,.gloss-term,button,input,select,.wg-badge,.card-icon,.sec-num,.psel-num,.hdr,.ach-popup,script,style,.search-modal,.sidecard,.gloss-tip,svg')) return NodeFilter.FILTER_REJECT; if (!re.test(node.nodeValue)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });
var nodes = [], n; while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(function (node) { var text = node.nodeValue; var out = document.createDocumentFragment(); var cursor = 0; var global = new RegExp(re.source, 'giu'); var m; while ((m = global.exec(text)) !== null) { if (m.index > cursor) out.appendChild(document.createTextNode(text.slice(cursor, m.index))); var found = m[0].toLowerCase(); var hit = allAliases.find(function (x) { return x.a.toLowerCase() === found; }); var g = hit ? GL[hit.i] : null; var sp = document.createElement('span'); sp.className = 'gloss-term'; sp.dataset.gloss = g ? g.term : ''; sp.textContent = m[0]; out.appendChild(sp); cursor = m.index + m[0].length; } if (cursor < text.length) out.appendChild(document.createTextNode(text.slice(cursor))); node.parentNode.replaceChild(out, node); });
root.__glossDone = true;
}
function initGlossaryTip() {
var tip = document.getElementById('gloss-tip'); if (!tip) return;
var GL = M6.glossary || []; var lockOpen = null;
function show(elm) { var g = GL.find(function (x) { return x.term === elm.dataset.gloss; }); if (!g) return; tip.innerHTML = '<b>' + g.term[0].toUpperCase() + g.term.slice(1) + '</b><div style="margin-top:4px">' + g.def + '</div>' + (g.sec ? '<div style="margin-top:6px;font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">См. ' + shortName(g.sec) + '</div>' : ''); if (window.renderMathInElement) renderMath(tip); var r = elm.getBoundingClientRect(); tip.classList.add('show'); var tw = tip.offsetWidth, th = tip.offsetHeight; var left = r.left, top = r.bottom + 8; if (left + tw > window.innerWidth - 12) left = window.innerWidth - tw - 12; if (top + th > window.innerHeight - 12) top = r.top - th - 8; tip.style.left = Math.max(8, left) + 'px'; tip.style.top = Math.max(8, top) + 'px'; }
function hide() { tip.classList.remove('show'); }
document.addEventListener('mouseover', function (e) { var elm = e.target.closest && e.target.closest('.gloss-term'); if (elm && !lockOpen) show(elm); });
document.addEventListener('mouseout', function (e) { var elm = e.target.closest && e.target.closest('.gloss-term'); if (elm && !lockOpen) hide(); });
document.addEventListener('click', function (e) { var elm = e.target.closest && e.target.closest('.gloss-term'); if (elm) { if (lockOpen === elm) { lockOpen = null; hide(); } else { lockOpen = elm; show(elm); } } else if (lockOpen && !e.target.closest('.gloss-tip')) { lockOpen = null; hide(); } });
}
/* ============================================================ SEARCH */
function buildSearchIndex() {
var arr = [];
paras().forEach(function (p) { arr.push({ kind: p.final ? 'Финал' : 'Параграф', title: p.num + ' ' + p.name, desc: p.sub || '', sec: p.id }); });
(M6.glossary || []).forEach(function (g) { arr.push({ kind: 'Понятие', title: g.term, desc: (g.def || '').replace(/\$/g, ''), sec: g.sec }); });
(M6.searchRows || []).forEach(function (r) { arr.push({ kind: r[0], title: r[1], desc: r[2], sec: r[3] }); });
return arr;
}
function initSearch() {
var modal = document.getElementById('search-modal'), inp = document.getElementById('search-input'), out = document.getElementById('search-results'), btn = document.getElementById('search-btn');
if (!modal || !inp || !out) return;
var INDEX = buildSearchIndex(); var cur = 0, rows = [];
function score(q, it) { var t = (it.title + ' ' + it.desc).toLowerCase(); if (t.includes(q)) return 100 + (it.title.toLowerCase().startsWith(q) ? 50 : 0); var s = 0; q.split(/\s+/).forEach(function (w) { if (w && t.includes(w)) s += 10; }); return s; }
function rank(q) { q = q.trim().toLowerCase(); if (!q) return INDEX.slice(0, 12); return INDEX.map(function (it) { return { it: it, s: score(q, it) }; }).filter(function (x) { return x.s > 0; }).sort(function (a, b) { return b.s - a.s; }).slice(0, 20).map(function (x) { return x.it; }); }
function render() { cur = 0; if (!rows.length) { out.innerHTML = '<div class="search-empty">Ничего не найдено</div>'; return; } out.innerHTML = rows.map(function (r, i) { return '<button class="search-row' + (i === 0 ? ' active' : '') + '" data-i="' + i + '"><div class="sr-kind">' + r.kind + '</div><div class="sr-title">' + r.title + '</div>' + (r.desc ? '<div class="sr-desc">' + (r.desc.length > 90 ? r.desc.slice(0, 90) + '…' : r.desc) + '</div>' : '') + '</button>'; }).join(''); out.querySelectorAll('.search-row').forEach(function (b) { b.addEventListener('click', function () { cur = +b.dataset.i; pick(); }); }); }
function pick() { var r = rows[cur]; if (!r) return; close(); goTo(r.sec); }
function move(d) { var items = out.querySelectorAll('.search-row'); if (!items.length) return; items[cur] && items[cur].classList.remove('active'); cur = (cur + d + items.length) % items.length; items[cur].classList.add('active'); items[cur].scrollIntoView({ block: 'nearest' }); }
function open() { modal.classList.add('show'); inp.value = ''; rows = rank(''); render(); setTimeout(function () { inp.focus(); }, 50); }
function close() { modal.classList.remove('show'); }
btn && btn.addEventListener('click', open);
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
inp.addEventListener('input', function () { rows = rank(inp.value); render(); });
inp.addEventListener('keydown', function (e) { if (e.key === 'ArrowDown') { e.preventDefault(); move(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); } else if (e.key === 'Enter') { e.preventDefault(); pick(); } else if (e.key === 'Escape') { e.preventDefault(); close(); } });
document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); if (modal.classList.contains('show')) close(); else open(); } });
}
function initSidebarToggle() {
var side = document.getElementById('col-side'), back = document.getElementById('col-side-backdrop'), btn = document.getElementById('sidebar-btn');
if (!side || !btn) return;
function open() { side.classList.add('open'); if (back) back.classList.add('show'); }
function close() { side.classList.remove('open'); if (back) back.classList.remove('show'); }
btn.addEventListener('click', function () { if (side.classList.contains('open')) close(); else open(); });
if (back) back.addEventListener('click', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
}
/* ============================================================ INIT */
function init() {
M6 = window.M6 || M6; /* перечитываем конфиг (порядок выполнения скриптов мог опередить объявление M6) */
loadProgress(); initTheme(); initSidebarToggle(); initGlossaryTip(); initSearch();
buildSections(); buildParaSelector(); refreshProgressUI(); loadServerReadState();
var first = (paras()[0] || {}).id; if (first) goTo(first);
if (M6.startAch) setTimeout(function () { achievement(M6.startAch[0], M6.startAch[1]); }, 600);
var foot = document.getElementById('m6-foot'); if (foot && M6.footer) foot.textContent = M6.footer;
if (window.LS && window.LS.xp) {
window.LS.xp.load().then(function (s) { if (s && s.xp > STATE.xp) { STATE.xp = s.xp; STATE.level = calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if (STATE.current) buildSidebar(STATE.current); } }).catch(function () {});
}
}
/* ============================================================ EXPORTS (для inline-билдеров) */
window.goTo = goTo;
window.makeCard = makeCard;
window.secNav = secNav;
window.readBtn = readBtn;
window.feedback = feedback;
window.renderMath = renderMath;
window.fmt = fmt; window.num = num;
window.addXp = addXp;
window.bumpProgress = bumpProgress;
window.achievement = achievement;
window.setupSorter = setupSorter;
window.confetti = confetti;
window.M6icon = M6icon;
window.M6engine = { goTo: goTo, ensureBuilt: ensureBuilt, refreshProgressUI: refreshProgressUI, buildSidebar: buildSidebar };
/* Запуск init — СТРОГО ПОСЛЕ экспортов в window. Иначе при defer-старте
(readyState='interactive') синхронная ветка else init() вызовет билдеры,
которые обращаются к makeCard/secNav/feedback ДО их экспорта → ReferenceError
→ перехват в ensureBuilt → заглушка. */
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();