fe378371bd
Реальная причина пустых §1 (заглушки) во всех главах: в math6_engine.js вызов init() стоял ВЫШЕ строк window.makeCard=…/secNav=…. При обычной загрузке через defer скрипт исполняется при readyState='interactive', поэтому ветка `else init()` срабатывала синхронно — init→goTo→buildP1() звал makeCard ДО его экспорта → ReferenceError 'makeCard is not defined' → перехват в ensureBuilt → заглушка. В jsdom-тестах баг не воспроизводился (там старт шёл через DOMContentLoaded, экспорты успевали). - init() теперь вызывается СТРОГО после всех window.* экспортов. - ensureBuilt перечитывает window.M6 (надёжнее против устаревшего замыкания). - html учебника всегда no-store (убрал кэш-причину стале-страниц). - регресс-тест: init() обязан идти после window.makeCard. Тесты 18/18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
395 lines
32 KiB
JavaScript
395 lines
32 KiB
JavaScript
/* 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.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();
|
||
})();
|