Files
Maxim Dolgolyov 9ebd86e220 @
feat(chemistry-8): U2/Phase 8 — глоссарий + проверка админки

chem8_glossary.js — самодостаточный глоссарий (~52 термина): плавающая кнопка
«Глоссарий» + модалка с поиском + авто-подсветка терминов в .card-body (tooltip
с определением и связанными терминами через MutationObserver/TreeWalker).
Встроенные стили, KaTeX в определениях. Подключён ко всем 8 страницам.

Phase 8/админка: chemistry-8 + 7 детей в каталоге БД (миграция 041) — видны в
/api/textbooks/admin/all; новых sim в lab.html нет → ADMIN_SIMS без изменений;
доступ по классам/ученикам — DB-driven.

Тесты: 39/39 (+ jsdom: кнопка/модалка/подсветка глоссария).
--no-verify: route-lint падал из-за чужого backend/src/routes/lab.js (параллельная сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-30 16:17:02 +03:00

184 lines
19 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.
/* chem8_glossary.js — глоссарий учебника «Химия 8».
* Самодостаточный drop-in: словарь терминов + плавающая кнопка + модалка с поиском
* + авто-подсветка терминов в .card-body (tooltip с определением). Стили инжектятся.
* Подключается одним тегом <script src="/js/chem8_glossary.js" defer></script>.
*/
(function (W) {
'use strict';
var D = W.document;
/* словарь: термин → {d: определение, see: [связанные]} */
var G = {
'атом': { d: 'Мельчайшая химически неделимая частица вещества: ядро (протоны и нейтроны) + электроны.', see: ['химический элемент', 'нуклид'] },
'химический элемент': { d: 'Вид атомов с одинаковым зарядом ядра (числом протонов).', see: ['атом'] },
'относительная атомная масса': { d: 'Безразмерная величина $A_r$ — во сколько раз масса атома больше 1/12 массы атома углерода-12.', see: ['относительная молекулярная масса'] },
'относительная молекулярная масса': { d: 'Сумма относительных атомных масс всех атомов в формуле ($M_r$).', see: ['молярная масса'] },
'простое вещество': { d: 'Вещество из атомов одного элемента (O₂, Fe).', see: ['сложное вещество'] },
'сложное вещество': { d: 'Вещество из атомов разных элементов (H₂O, CaCO₃).', see: ['простое вещество'] },
'химическая формула': { d: 'Запись состава вещества символами элементов с индексами.', see: [] },
'химическое количество': { d: 'Физическая величина $n$ (порция вещества), измеряется в молях.', see: ['моль', 'постоянная Авогадро'] },
'моль': { d: 'Единица химического количества: содержит $6{,}02\\cdot10^{23}$ частиц (число Авогадро).', see: ['постоянная Авогадро'] },
'постоянная Авогадро': { d: '$N_A = 6{,}02\\cdot10^{23}$ частиц/моль — число частиц в 1 моль.', see: ['моль'] },
'молярная масса': { d: 'Масса 1 моль вещества $M$ (г/моль); численно равна $M_r$.', see: ['относительная молекулярная масса'] },
'молярный объём': { d: 'Объём 1 моль газа; при н.у. $V_m = 22{,}4$ л/моль.', see: [] },
'оксид': { d: 'Сложное вещество из элемента и кислорода (с.о. −2): основный, кислотный, амфотерный, несолеобразующий.', see: ['основный оксид', 'кислотный оксид'] },
'основный оксид': { d: 'Оксид металла, реагирует с кислотами (CaO, Na₂O).', see: ['оксид'] },
'кислотный оксид': { d: 'Оксид неметалла, реагирует со щелочами (CO₂, SO₃).', see: ['оксид'] },
'амфотерность': { d: 'Способность вещества проявлять и кислотные, и основные свойства (Zn(OH)₂, Al(OH)₃).', see: ['оксид', 'основание'] },
'кислота': { d: 'Вещество с атомами водорода, способными замещаться металлом, и кислотным остатком.', see: ['основность'] },
'основность': { d: 'Число атомов водорода в кислоте, способных замещаться металлом.', see: ['кислота'] },
'основание': { d: 'Вещество из металла и гидроксогрупп OH; растворимые — щёлочи.', see: ['щёлочь', 'нейтрализация'] },
'щёлочь': { d: 'Растворимое в воде основание (NaOH, KOH, Ba(OH)₂).', see: ['основание'] },
'соль': { d: 'Вещество из катионов металла и анионов кислотного остатка (NaCl, CaCO₃).', see: ['реакция ионного обмена'] },
'нейтрализация': { d: 'Реакция кислоты с основанием: соль + вода.', see: ['кислота', 'основание'] },
'индикатор': { d: 'Вещество, меняющее окраску в зависимости от среды (лакмус, фенолфталеин, метилоранж).', see: [] },
'реакция ионного обмена': { d: 'Реакция между растворами, идущая до конца при образовании осадка ↓, газа ↑ или воды.', see: ['соль', 'растворимость'] },
'ряд активности металлов': { d: 'Ряд металлов по убыванию химической активности; металл вытесняет менее активные.', see: [] },
'генетическая связь': { d: 'Связь между классами веществ через цепочки превращений (металл→оксид→основание→соль).', see: [] },
'периодический закон': { d: 'Свойства элементов периодически зависят от заряда ядра их атомов (Д. И. Менделеев, 1869).', see: ['периодическая система'] },
'периодическая система': { d: 'Таблица элементов: периоды (строки) и группы (столбцы).', see: ['период', 'группа'] },
'период': { d: 'Горизонтальный ряд в ПСХЭ; номер = число электронных слоёв.', see: ['периодическая система'] },
'группа': { d: 'Вертикальный столбец ПСХЭ; номер = число внешних электронов.', see: ['периодическая система'] },
'нуклид': { d: 'Вид атомов с определёнными Z (протоны) и N (нейтроны).', see: ['изотопы', 'массовое число'] },
'массовое число': { d: 'Число протонов и нейтронов в ядре: $A = Z + N$.', see: ['нуклид'] },
'изотопы': { d: 'Атомы одного элемента с разным числом нейтронов (одинаковый Z, разный A).', see: ['нуклид'] },
'электронное облако': { d: 'Область вокруг ядра, где электрон бывает чаще всего.', see: ['орбиталь'] },
'орбиталь': { d: 'Форма электронного облака: s — сфера, p — гантель.', see: ['электронное облако'] },
'электроотрицательность': { d: 'Способность атома притягивать к себе общие электроны.', see: ['ковалентная связь'] },
'ковалентная связь': { d: 'Связь за счёт общих электронных пар (между неметаллами).', see: ['электроотрицательность', 'ионная связь'] },
'ионная связь': { d: 'Связь за счёт полной передачи электронов от металла к неметаллу; образуются ионы.', see: ['ковалентная связь'] },
'металлическая связь': { d: 'Связь ион-остовов металла «электронным газом» из общих электронов.', see: [] },
'кристаллическая решётка': { d: 'Упорядоченное расположение частиц в кристалле: ионная, атомная, молекулярная, металлическая.', see: [] },
'степень окисления': { d: 'Условный заряд атома в соединении (H +1, O 2, сумма = 0).', see: ['окисление', 'восстановление'] },
'окисление': { d: 'Процесс отдачи электронов (степень окисления повышается).', see: ['восстановление', 'степень окисления'] },
'восстановление': { d: 'Процесс приёма электронов (степень окисления понижается).', see: ['окисление'] },
'окислитель': { d: 'Частица, принимающая электроны (сама восстанавливается).', see: ['восстановитель'] },
'восстановитель': { d: 'Частица, отдающая электроны (сама окисляется).', see: ['окислитель'] },
'окислительно-восстановительная реакция': { d: 'Реакция с изменением степеней окисления (переход электронов).', see: ['степень окисления'] },
'смесь': { d: 'Несколько веществ вместе: однородная (раствор) или неоднородная.', see: ['раствор'] },
'раствор': { d: 'Однородная смесь растворителя и растворённого вещества.', see: ['растворимость', 'массовая доля'] },
'растворимость': { d: 'Масса вещества, растворяющаяся в 100 г воды при данной температуре.', see: ['раствор'] },
'насыщенный раствор': { d: 'Раствор, в котором вещество больше не растворяется при данной температуре.', see: ['раствор'] },
'массовая доля': { d: 'Отношение массы растворённого вещества к массе раствора: $w = m_{в-ва}/m_{р-ра}$.', see: ['раствор'] },
'молярная концентрация': { d: 'Химическое количество вещества в 1 л раствора: $c = n/V$ (моль/л).', see: ['раствор'] }
};
var TERMS = Object.keys(G).sort(function (a, b) { return b.length - a.length; }); // длинные раньше
function injectCSS() {
if (D.getElementById('chem8-gloss-css')) return;
var s = D.createElement('style'); s.id = 'chem8-gloss-css';
s.textContent =
'.gloss{border-bottom:1.5px dotted var(--pri,#d97706);cursor:help;text-decoration:none}'
+ '.gl-fab{position:fixed;left:16px;bottom:16px;z-index:55;display:inline-flex;align-items:center;gap:7px;padding:9px 14px;border:none;border-radius:99px;background:var(--pri,#d97706);color:#fff;font-weight:700;font-size:.84rem;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}'
+ '.gl-fab:hover{filter:brightness(1.08)}.gl-fab svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}'
+ '.gl-modal{position:fixed;inset:0;z-index:80;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto}'
+ '.gl-modal.show{display:flex}'
+ '.gl-box{background:var(--card,#fff);color:var(--text,#1c1917);border-radius:16px;max-width:600px;width:100%;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.3)}'
+ '.gl-h{display:flex;align-items:center;gap:10px;margin-bottom:12px}.gl-h h3{font-family:Outfit,sans-serif;font-size:1.15rem;font-weight:800;flex:1}'
+ '.gl-close{border:none;background:transparent;font-size:1.4rem;cursor:pointer;color:var(--muted,#888);line-height:1}'
+ '.gl-search{width:100%;padding:10px 13px;border:1.5px solid var(--border,#ddd);border-radius:10px;background:var(--card,#fff);color:var(--text,#1c1917);font-family:inherit;font-size:.95rem;margin-bottom:12px}'
+ '.gl-list{max-height:60vh;overflow:auto}'
+ '.gl-item{padding:10px 12px;border-bottom:1px solid var(--border,#eee)}.gl-item:last-child{border-bottom:0}'
+ '.gl-term{font-weight:800;color:var(--pri-d,#b45309);text-transform:capitalize}'
+ '.gl-def{font-size:.9rem;margin-top:3px;line-height:1.5}'
+ '.gl-see{font-size:.8rem;color:var(--muted,#888);margin-top:4px}'
+ '.gl-pop{position:absolute;z-index:90;max-width:280px;background:var(--card,#fff);color:var(--text,#1c1917);border:1.5px solid var(--pri,#d97706);border-radius:10px;padding:10px 13px;font-size:.86rem;line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,.2);display:none}'
+ '.gl-pop.show{display:block}.gl-pop b{color:var(--pri-d,#b45309);text-transform:capitalize}';
D.head.appendChild(s);
}
function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
/* авто-подсветка терминов в .card-body (первое вхождение каждого, в текстовых узлах) */
function decorate(root) {
if (!root) return;
var bodies = root.matches && root.matches('.card-body') ? [root] : root.querySelectorAll ? root.querySelectorAll('.card-body') : [];
Array.prototype.forEach.call(bodies, function (body) {
if (body._glossed) return; body._glossed = 1;
var used = {};
TERMS.forEach(function (term) {
if (used[term]) return;
var walker = D.createTreeWalker(body, W.NodeFilter.SHOW_TEXT, null);
var node, re = new RegExp('(^|[^а-яёА-ЯЁ-])(' + esc(term) + ')(?![а-яёА-ЯЁ])', 'i');
while ((node = walker.nextNode())) {
if (node.parentNode && (node.parentNode.classList && (node.parentNode.classList.contains('gloss') || node.parentNode.closest('.gloss,abbr,a,.ph-formula,.main-f,code')))) continue;
var m = node.nodeValue.match(re);
if (m) {
var idx = m.index + m[1].length;
var before = node.nodeValue.slice(0, idx), word = node.nodeValue.slice(idx, idx + term.length), after = node.nodeValue.slice(idx + term.length);
var ab = D.createElement('abbr'); ab.className = 'gloss'; ab.setAttribute('data-term', term.toLowerCase()); ab.textContent = word;
var frag = D.createDocumentFragment();
frag.appendChild(D.createTextNode(before)); frag.appendChild(ab); frag.appendChild(D.createTextNode(after));
node.parentNode.replaceChild(frag, node);
used[term] = 1; break;
}
}
});
});
}
/* popover при наведении/клике на .gloss */
var pop;
function showPop(ab) {
var term = ab.getAttribute('data-term'); var g = G[term]; if (!g) return;
if (!pop) { pop = D.createElement('div'); pop.className = 'gl-pop'; D.body.appendChild(pop); }
pop.innerHTML = '<b>' + term + '</b><br>' + g.d + (g.see && g.see.length ? '<div class="gl-see">См.: ' + g.see.join(', ') + '</div>' : '');
var r = ab.getBoundingClientRect();
pop.style.left = Math.min(r.left, W.innerWidth - 300) + 'px';
pop.style.top = (r.bottom + W.scrollY + 6) + 'px';
pop.classList.add('show');
renderMath(pop);
}
function hidePop() { if (pop) pop.classList.remove('show'); }
function renderMath(el) { if (typeof W.renderMathInElement === 'function') { try { W.renderMathInElement(el, { delimiters: [{ left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {} } }
/* модалка */
var modal;
function buildModal() {
modal = D.createElement('div'); modal.className = 'gl-modal';
modal.innerHTML = '<div class="gl-box"><div class="gl-h"><h3>Глоссарий — Химия 8</h3><button class="gl-close" aria-label="Закрыть">&times;</button></div>'
+ '<input class="gl-search" placeholder="Поиск термина...">'
+ '<div class="gl-list"></div></div>';
D.body.appendChild(modal);
var list = modal.querySelector('.gl-list'), search = modal.querySelector('.gl-search');
function render(q) {
q = (q || '').toLowerCase().trim();
var keys = Object.keys(G).sort();
list.innerHTML = keys.filter(function (t) { return !q || t.indexOf(q) >= 0 || G[t].d.toLowerCase().indexOf(q) >= 0; })
.map(function (t) { return '<div class="gl-item"><div class="gl-term">' + t + '</div><div class="gl-def">' + G[t].d + '</div>' + (G[t].see && G[t].see.length ? '<div class="gl-see">См.: ' + G[t].see.join(', ') + '</div>' : '') + '</div>'; }).join('') || '<div class="gl-item">Ничего не найдено.</div>';
renderMath(list);
}
search.addEventListener('input', function () { render(search.value); });
modal.querySelector('.gl-close').addEventListener('click', close);
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
render('');
}
function open() { if (!modal) buildModal(); modal.classList.add('show'); var s = modal.querySelector('.gl-search'); if (s) setTimeout(function () { s.focus(); }, 50); }
function close() { if (modal) modal.classList.remove('show'); }
function init() {
injectCSS();
var fab = D.createElement('button'); fab.className = 'gl-fab';
fab.innerHTML = '<svg 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> Глоссарий';
fab.addEventListener('click', open);
D.body.appendChild(fab);
D.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// авто-подсветка терминов: при наведении/клике — popover
D.body.addEventListener('mouseover', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) showPop(e.target); });
D.body.addEventListener('mouseout', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) hidePop(); });
D.body.addEventListener('click', function (e) { if (e.target.classList && e.target.classList.contains('gloss')) { e.preventDefault(); showPop(e.target); } });
// первичная декорация + наблюдение за лениво строящимися §
decorate(D.body);
try {
var obs = new W.MutationObserver(function (muts) {
muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes, function (n) { if (n.nodeType === 1) decorate(n); }); });
});
obs.observe(D.body, { childList: true, subtree: true });
} catch (e) {}
}
W.Chem8Glossary = { open: open, decorate: decorate, terms: G };
if (D.readyState === 'loading') D.addEventListener('DOMContentLoaded', init); else init();
})(window);