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> @
184 lines
19 KiB
JavaScript
184 lines
19 KiB
JavaScript
/* 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="Закрыть">×</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);
|