merge: feature/lab-content-engine → master
Контент-движок лаборатории (фазы 0-5): LabRegistry, data-driven регистрация, вынос тел в labs-bodies.html, ленивая загрузка кода, БД-каталог lab_sims + API + админка, курикулумные связи lab_sim_links + двусторонняя навигация. Плюс накопленная работа параллельных сессий (chemistry-8, phys7, biochem, optics). Разрешение конфликтов: frontend/lab.html — версия feature (контент-движок); opticsbench.js / seed_biochem_challenges.js / BIOCHEM_UPGRADE.md / biochem-pathways-plan.md — версия master (более свежая работа парал. сессий). Тесты: 160, 157 pass, 3 fail (pre-existing baseline auth.test.js). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,90 +1,78 @@
|
||||
'use strict';
|
||||
/* admin → sims (simulations) section */
|
||||
/* admin → sims (simulations) section — контент-движок, Фазы 4-5.
|
||||
*
|
||||
* Каталог берётся из БД (/api/lab/sims), а НЕ из захардкоженного списка.
|
||||
* Управление: вкл/выкл (зеркалится в legacy sim_disabled_ids), «рекомендуемая»,
|
||||
* курикулумные связи (Фаза 5). Мастер-тумблер модуля — /api/settings/sims. */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
|
||||
// Full list of available (non-null id) sims mirrored from /lab
|
||||
const ADMIN_SIMS = [
|
||||
{ id: 'graph', cat: 'Математика', title: 'График функции' },
|
||||
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
|
||||
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
|
||||
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
|
||||
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
|
||||
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
|
||||
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
|
||||
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
|
||||
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
|
||||
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
|
||||
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
|
||||
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
|
||||
{ id: 'emfield', cat: 'Физика', title: 'Электромагнитные поля' },
|
||||
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
|
||||
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
|
||||
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
|
||||
{ id: 'opticsbench', cat: 'Физика', title: 'Оптическая скамья' },
|
||||
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
|
||||
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
|
||||
{ id: 'heatengine', cat: 'Физика', title: 'Тепловые двигатели' },
|
||||
{ id: 'radioactive', cat: 'Физика', title: 'Радиоактивный распад' },
|
||||
{ id: 'race', cat: 'Физика', title: 'Гонка с задачами' },
|
||||
{ id: 'logic', cat: 'Физика', title: 'Логические схемы' },
|
||||
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
|
||||
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
|
||||
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
|
||||
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
|
||||
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
|
||||
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
|
||||
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
|
||||
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
|
||||
{ id: 'stoichiometry', cat: 'Химия', title: 'Стехиометрия' },
|
||||
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
|
||||
{ id: 'qualanalysis', cat: 'Химия', title: 'Качественный анализ' },
|
||||
{ id: 'periodic', cat: 'Химия', title: 'Периодическая таблица' },
|
||||
{ id: 'organic', cat: 'Химия', title: 'Органическая химия' },
|
||||
{ id: 'solutions', cat: 'Химия', title: 'Растворы' },
|
||||
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
|
||||
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
|
||||
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
|
||||
];
|
||||
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игры' };
|
||||
const CAT_ORDER = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||
|
||||
let _simsSettings = { module_disabled: false, disabled_ids: [] };
|
||||
let _moduleDisabled = false;
|
||||
let _sims = []; // [{id,cat,title,enabled,featured,tags,subject,grade,sort}]
|
||||
let _textbooks = null; // кэш каталога учебников для выпадающего списка связей
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await LS.api('/api/settings/sims');
|
||||
_simsSettings = data;
|
||||
const data = await LS.api('/api/lab/sims');
|
||||
_moduleDisabled = !!data.module_disabled;
|
||||
_sims = Array.isArray(data.sims) ? data.sims : [];
|
||||
_render();
|
||||
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); }
|
||||
} catch (e) { LS.toast('Ошибка загрузки симуляций: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
function _render() {
|
||||
// master toggle
|
||||
const masterChk = document.getElementById('sims-master-chk');
|
||||
if (masterChk) masterChk.checked = !_simsSettings.module_disabled;
|
||||
if (masterChk) masterChk.checked = !_moduleDisabled;
|
||||
|
||||
// per-sim cards
|
||||
const grid = document.getElementById('sims-grid');
|
||||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||||
// group by category
|
||||
if (!grid) return;
|
||||
|
||||
// group by category, preserving catalogue sort within group
|
||||
const byCat = {};
|
||||
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
||||
_sims.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
|
||||
const cats = CAT_ORDER.filter(c => byCat[c]).concat(
|
||||
Object.keys(byCat).filter(c => !CAT_ORDER.includes(c)));
|
||||
|
||||
let html = '';
|
||||
Object.entries(byCat).forEach(([cat, sims]) => {
|
||||
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`;
|
||||
sims.forEach(s => {
|
||||
const enabled = !dis.has(s.id);
|
||||
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}">
|
||||
cats.forEach(cat => {
|
||||
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(CAT_LABEL[cat] || cat)}</div>`;
|
||||
byCat[cat].forEach(s => {
|
||||
const tags = (s.tags || []).map(t => esc(t)).join(', ');
|
||||
html += `<div class="perm-card${s.enabled ? ' enabled' : ''}" id="simcard-${esc(s.id)}" style="flex-wrap:wrap">
|
||||
<div class="perm-info">
|
||||
<div class="perm-label">${esc(s.title)}</div>
|
||||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
|
||||
<div class="perm-label">
|
||||
${esc(s.title)}
|
||||
<button class="sim-star" title="${s.featured ? 'Убрать из рекомендуемых' : 'Сделать рекомендуемой'}"
|
||||
onclick="simToggleFeatured('${esc(s.id)}', ${s.featured ? 'false' : 'true'})"
|
||||
style="background:none;border:none;cursor:pointer;padding:0 0 0 6px;vertical-align:middle">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;${s.featured ? 'fill:var(--amber);stroke:var(--amber)' : 'fill:none;stroke:var(--text-3)'}"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}${tags ? ' · ' + tags : ''}</div>
|
||||
</div>
|
||||
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button class="sim-links-btn" title="Связи с программой"
|
||||
onclick="simToggleLinks('${esc(s.id)}')"
|
||||
style="background:none;border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;cursor:pointer;padding:4px 8px;font-size:.7rem;color:var(--text-2);display:inline-flex;align-items:center;gap:4px">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Связи
|
||||
</button>
|
||||
<label class="perm-toggle" title="${s.enabled ? 'Отключить' : 'Включить'}">
|
||||
<input type="checkbox" ${s.enabled ? 'checked' : ''} onchange="simToggleOne('${esc(s.id)}', this.checked)" />
|
||||
<span class="perm-track"></span>
|
||||
<span class="perm-thumb"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="sim-links-panel" id="simlinks-${esc(s.id)}" style="display:none;flex-basis:100%;width:100%;margin-top:8px;padding-top:8px;border-top:1px solid var(--border,rgba(255,255,255,.1))"></div>
|
||||
</div>`;
|
||||
});
|
||||
});
|
||||
@@ -95,26 +83,126 @@
|
||||
async function simsMasterToggle(checked) {
|
||||
try {
|
||||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
|
||||
_simsSettings.module_disabled = !checked;
|
||||
_moduleDisabled = !checked;
|
||||
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function simToggleOne(simId, enabled) {
|
||||
const dis = new Set(_simsSettings.disabled_ids || []);
|
||||
if (enabled) dis.delete(simId); else dis.add(simId);
|
||||
const disabled_ids = [...dis];
|
||||
try {
|
||||
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) });
|
||||
_simsSettings.disabled_ids = disabled_ids;
|
||||
await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ enabled }) });
|
||||
const s = _sims.find(x => x.id === simId);
|
||||
if (s) s.enabled = enabled;
|
||||
const card = document.getElementById('simcard-' + simId);
|
||||
if (card) card.classList.toggle('enabled', enabled);
|
||||
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning');
|
||||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function simToggleFeatured(simId, featured) {
|
||||
try {
|
||||
await LS.api('/api/lab/sims/' + encodeURIComponent(simId), { method: 'PATCH', body: JSON.stringify({ featured }) });
|
||||
const s = _sims.find(x => x.id === simId);
|
||||
if (s) s.featured = featured;
|
||||
_render();
|
||||
LS.toast(featured ? `«${simId}» в рекомендуемых` : `«${simId}» убрана из рекомендуемых`, 'success');
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ── Фаза 5: редактор курикулумных связей (inline-панель под карточкой) ── */
|
||||
|
||||
async function _ensureTextbooks() {
|
||||
if (_textbooks) return _textbooks;
|
||||
try {
|
||||
const data = await LS.api('/api/access/catalog');
|
||||
_textbooks = (data && data.textbooks) || [];
|
||||
} catch (e) { _textbooks = []; }
|
||||
return _textbooks;
|
||||
}
|
||||
|
||||
async function simToggleLinks(simId) {
|
||||
const panel = document.getElementById('simlinks-' + simId);
|
||||
if (!panel) return;
|
||||
if (panel.style.display !== 'none') { panel.style.display = 'none'; return; }
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = '<div style="font-size:.72rem;color:var(--text-3)">Загрузка связей…</div>';
|
||||
try {
|
||||
const [rel] = await Promise.all([
|
||||
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related'),
|
||||
_ensureTextbooks(),
|
||||
]);
|
||||
_renderLinksPanel(simId, rel);
|
||||
} catch (e) {
|
||||
panel.innerHTML = '<div style="font-size:.72rem;color:var(--pink,#f15bb5)">Ошибка: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLinksPanel(simId, rel) {
|
||||
const panel = document.getElementById('simlinks-' + simId);
|
||||
if (!panel) return;
|
||||
const links = (rel && rel.links) || {};
|
||||
const tb = links.textbook || [];
|
||||
|
||||
let html = '<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-3);margin-bottom:6px">Связи с учебниками</div>';
|
||||
|
||||
if (tb.length) {
|
||||
html += '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
|
||||
tb.forEach(l => {
|
||||
html += `<span style="display:inline-flex;align-items:center;gap:6px;font-size:.72rem;padding:3px 6px 3px 10px;border-radius:999px;background:rgba(155,93,229,.14);color:var(--violet);border:1px solid rgba(155,93,229,.3)">
|
||||
${esc(l.label || l.ref_id)}
|
||||
<button title="Удалить связь" onclick="simDelLink('${esc(simId)}', ${Number(l.id)})"
|
||||
style="background:none;border:none;cursor:pointer;color:inherit;padding:0;line-height:1;font-size:.95rem;opacity:.7">×</button>
|
||||
</span>`;
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div style="font-size:.72rem;color:var(--text-3);margin-bottom:8px">Пока нет связей</div>';
|
||||
}
|
||||
|
||||
// add-form: textbook <select> + button
|
||||
const linkedSlugs = new Set(tb.map(l => l.ref_id));
|
||||
const opts = (_textbooks || [])
|
||||
.filter(t => !linkedSlugs.has(t.slug))
|
||||
.map(t => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('');
|
||||
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<select id="simlink-sel-${esc(simId)}" style="flex:1;min-width:180px;font-size:.75rem;padding:5px 8px;border-radius:8px;background:var(--bg-2,#1a1a2e);color:var(--text);border:1px solid var(--border,rgba(255,255,255,.14))">
|
||||
<option value="">— выбрать учебник —</option>${opts}
|
||||
</select>
|
||||
<button onclick="simAddLink('${esc(simId)}')"
|
||||
style="font-size:.75rem;padding:5px 12px;border-radius:8px;border:1px solid var(--violet);background:rgba(155,93,229,.15);color:var(--violet);cursor:pointer">Добавить</button>
|
||||
</div>`;
|
||||
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
async function simAddLink(simId) {
|
||||
const sel = document.getElementById('simlink-sel-' + simId);
|
||||
const slug = sel && sel.value;
|
||||
if (!slug) { LS.toast('Выберите учебник', 'warning'); return; }
|
||||
try {
|
||||
await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links',
|
||||
{ method: 'POST', body: JSON.stringify({ kind: 'textbook', ref_id: slug }) });
|
||||
LS.toast('Связь добавлена', 'success');
|
||||
const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related');
|
||||
_renderLinksPanel(simId, rel);
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function simDelLink(simId, linkId) {
|
||||
try {
|
||||
await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/links/' + linkId, { method: 'DELETE' });
|
||||
LS.toast('Связь удалена', 'success');
|
||||
const rel = await LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related');
|
||||
_renderLinksPanel(simId, rel);
|
||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||||
}
|
||||
|
||||
window.simsMasterToggle = simsMasterToggle;
|
||||
window.simToggleOne = simToggleOne;
|
||||
window.simToggleFeatured = simToggleFeatured;
|
||||
window.simToggleLinks = simToggleLinks;
|
||||
window.simAddLink = simAddLink;
|
||||
window.simDelLink = simDelLink;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.sims = {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/* chem8_ch1_widgets.js — виджеты Главы 1 «Важнейшие классы неорганических соединений».
|
||||
* Монтируются движком: window.CHEM8_WIDGETS[id] / window.FLAG_MOUNTS[id].
|
||||
* Используют window.Chem8: classifier, indicatorScale, solubilityTable, activitySeries.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function xp(n, s) { try { if (W.addXp) W.addXp(n, s); } catch (e) {} }
|
||||
|
||||
/* §10 — классификатор оксидов */
|
||||
function mount_p10() {
|
||||
var el = $('c-ox-cls'); if (!el || el._b || !C().classifier) return; el._b = 1;
|
||||
C().classifier(el, {
|
||||
items: [
|
||||
{ id: 'Na2O', label: 'Na₂O', cat: 'осн' }, { id: 'CaO', label: 'CaO', cat: 'осн' },
|
||||
{ id: 'CO2', label: 'CO₂', cat: 'кисл' }, { id: 'SO3', label: 'SO₃', cat: 'кисл' }, { id: 'P2O5', label: 'P₂O₅', cat: 'кисл' },
|
||||
{ id: 'ZnO', label: 'ZnO', cat: 'амф' }, { id: 'Al2O3', label: 'Al₂O₃', cat: 'амф' },
|
||||
{ id: 'CO', label: 'CO', cat: 'несол' }, { id: 'N2O', label: 'N₂O', cat: 'несол' }
|
||||
],
|
||||
buckets: [{ cat: 'осн', label: 'Основные' }, { cat: 'кисл', label: 'Кислотные' }, { cat: 'амф', label: 'Амфотерные' }, { cat: 'несол', label: 'Несолеобразующие' }],
|
||||
onCheck: function (ok) { if (ok) xp(8, 'p10-cls'); }
|
||||
});
|
||||
}
|
||||
|
||||
/* §13 — классификатор кислот + индикатор */
|
||||
function mount_p13() {
|
||||
var cls = $('c-acid-cls');
|
||||
if (cls && !cls._b && C().classifier) { cls._b = 1; C().classifier(cls, {
|
||||
items: [
|
||||
{ id: 'HCl', label: 'HCl', cat: 'без' }, { id: 'H2S', label: 'H₂S', cat: 'без' }, { id: 'HBr', label: 'HBr', cat: 'без' },
|
||||
{ id: 'H2SO4', label: 'H₂SO₄', cat: 'кисл' }, { id: 'HNO3', label: 'HNO₃', cat: 'кисл' }, { id: 'H3PO4', label: 'H₃PO₄', cat: 'кисл' }
|
||||
],
|
||||
buckets: [{ cat: 'без', label: 'Бескислородные' }, { cat: 'кисл', label: 'Кислородсодержащие' }],
|
||||
onCheck: function (ok) { if (ok) xp(8, 'p13-cls'); }
|
||||
}); }
|
||||
var ind = $('c-acid-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'лакмус', ph: 2 }); }
|
||||
}
|
||||
|
||||
/* §14 — ряд активности + индикатор */
|
||||
function mount_p14() {
|
||||
var act = $('c-acid-act'); if (act && !act._b && C().activitySeries) { act._b = 1; C().activitySeries(act, {}); }
|
||||
var ind = $('c-acid-ind2'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'метилоранж', ph: 2 }); }
|
||||
}
|
||||
|
||||
/* §16 — классификатор оснований + индикатор (фенолфталеин) */
|
||||
function mount_p16() {
|
||||
var cls = $('c-base-cls');
|
||||
if (cls && !cls._b && C().classifier) { cls._b = 1; C().classifier(cls, {
|
||||
items: [
|
||||
{ id: 'NaOH', label: 'NaOH', cat: 'щел' }, { id: 'KOH', label: 'KOH', cat: 'щел' }, { id: 'BaOH', label: 'Ba(OH)₂', cat: 'щел' },
|
||||
{ id: 'CuOH', label: 'Cu(OH)₂', cat: 'нер' }, { id: 'FeOH', label: 'Fe(OH)₃', cat: 'нер' }, { id: 'MgOH', label: 'Mg(OH)₂', cat: 'нер' }
|
||||
],
|
||||
buckets: [{ cat: 'щел', label: 'Щёлочи (растворимые)' }, { cat: 'нер', label: 'Нерастворимые' }],
|
||||
onCheck: function (ok) { if (ok) xp(8, 'p16-cls'); }
|
||||
}); }
|
||||
var ind = $('c-base-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'фенолфталеин', ph: 12 }); }
|
||||
}
|
||||
|
||||
/* §17 — индикатор нейтрализации */
|
||||
function mount_p17() { var ind = $('c-neutral-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'фенолфталеин', ph: 12 }); } }
|
||||
|
||||
/* §18 — индикатор (ПР2 нейтрализация) */
|
||||
function mount_p18() { var ind = $('c-pr2-ind'); if (ind && !ind._b && C().indicatorScale) { ind._b = 1; C().indicatorScale(ind, { indicator: 'лакмус', ph: 7 }); } }
|
||||
|
||||
/* §19 — таблица растворимости */
|
||||
function mount_p19() { var s = $('c-salt-sol'); if (s && !s._b && C().solubilityTable) { s._b = 1; C().solubilityTable(s, {}); } }
|
||||
|
||||
/* §20 — растворимость + ряд активности (соль + металл) */
|
||||
function mount_p20() {
|
||||
var s = $('c-salt-sol2'); if (s && !s._b && C().solubilityTable) { s._b = 1; C().solubilityTable(s, {}); }
|
||||
var a = $('c-salt-act'); if (a && !a._b && C().activitySeries) { a._b = 1; C().activitySeries(a, {}); }
|
||||
}
|
||||
|
||||
/* §23 — пошаговый решатель расчётных задач по классам */
|
||||
var ST = [
|
||||
{ eq: 'CaO + 2HCl → CaCl₂ + H₂O', given: 'Дано: m(CaO) = 28 г. Найти m(CaCl₂). M(CaO)=56, M(CaCl₂)=111.',
|
||||
steps: ['n(CaO) = m/M = 28/56 = 0,5 моль.', 'n(CaO):n(CaCl₂) = 1:1 → n(CaCl₂)=0,5 моль.', 'm(CaCl₂) = n·M = 0,5·111 = 55,5 г. Ответ: 55,5 г.'] },
|
||||
{ eq: 'Zn + H₂SO₄ → ZnSO₄ + H₂↑', given: 'Дано: n(Zn) = 2 моль. Найти V(H₂) при н.у.',
|
||||
steps: ['n(Zn):n(H₂) = 1:1 → n(H₂)=2 моль.', 'V(H₂) = n·Vm = 2·22,4 = 44,8 л. Ответ: 44,8 л.'] },
|
||||
{ eq: '2NaOH + H₂SO₄ → Na₂SO₄ + 2H₂O', given: 'Дано: n(H₂SO₄) = 0,5 моль. Найти n(NaOH).',
|
||||
steps: ['n(NaOH):n(H₂SO₄) = 2:1 → n(NaOH)=2·0,5=1 моль.', 'Ответ: 1 моль NaOH.'] }
|
||||
];
|
||||
function mount_p23() {
|
||||
var pick = $('c-calc-pick'), out = $('c-calc-out'), bStep = $('c-calc-step'), bAll = $('c-calc-all'); if (!pick || pick._b) return; pick._b = 1;
|
||||
ST.forEach(function (p, i) { var o = document.createElement('option'); o.value = i; o.textContent = p.eq; pick.appendChild(o); });
|
||||
var cur = 0, shown = 0;
|
||||
function render() {
|
||||
var p = ST[cur];
|
||||
var html = '<b>' + p.eq + '</b><br><span style="color:var(--muted)">' + p.given + '</span><div style="margin-top:8px">';
|
||||
for (var i = 0; i < shown; i++) html += '<div class="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
|
||||
if (shown === 0) html += '<span style="color:var(--muted)">Нажмите «Следующий шаг».</span>';
|
||||
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
|
||||
}
|
||||
pick.addEventListener('change', function () { cur = +pick.value; shown = 0; render(); });
|
||||
bStep.addEventListener('click', function () { if (shown < ST[cur].steps.length) { shown++; render(); } });
|
||||
bAll.addEventListener('click', function () { shown = ST[cur].steps.length; render(); });
|
||||
render();
|
||||
}
|
||||
|
||||
/* §22 — генетическая карта классов */
|
||||
function mount_p22() { var el = $('c-genetic'); if (el && !el._b && C().geneticMap) { el._b = 1; C().geneticMap(el, {}); } }
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"ox","t":"Оксид","x":20,"y":22},{"id":"acid","t":"Кислота","x":160,"y":22,"c":"#2563eb"},{"id":"base","t":"Основание","x":20,"y":95,"c":"#0d9488"},{"id":"salt","t":"Соль","x":330,"y":55,"c":"#d97706"}],"edges":[{"f":"ox","t":"acid","label":"кислотный оксид + вода → кислота"},{"f":"acid","t":"base","label":"нейтрализация → соль + вода"},{"f":"acid","t":"salt","label":"кислота + металл/оксид → соль"},{"f":"base","t":"salt","label":"основание + кислота → соль"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p13: mount_p13, p16: mount_p16, p17: mount_p17, p18: mount_p18 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p10: mount_p10, p14: mount_p14, p19: mount_p19, p20: mount_p20, p22: mount_p22, p23: mount_p23 };
|
||||
})(window);
|
||||
@@ -0,0 +1,76 @@
|
||||
/* chem8_ch2_widgets.js — виджеты Главы 2 «Периодический закон и ПСХЭ».
|
||||
* Использует window.Chem8: miniPeriodic, testTube.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
/* интерактивная ПСХЭ с кнопками-режимами подсветки */
|
||||
function periodicModes(mountId, modes) {
|
||||
var el = $(mountId); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
|
||||
var bar = document.createElement('div'); bar.className = 'pt-modes';
|
||||
var grid = document.createElement('div');
|
||||
modes.forEach(function (m, i) {
|
||||
var b = document.createElement('button'); b.className = 'btn'; b.textContent = m.l;
|
||||
b.addEventListener('click', function () {
|
||||
bar.querySelectorAll('.btn').forEach(function (x) { x.classList.remove('primary'); });
|
||||
b.classList.add('primary'); if (api) api.highlight(m.k);
|
||||
});
|
||||
bar.appendChild(b);
|
||||
});
|
||||
el.appendChild(bar); el.appendChild(grid);
|
||||
var api = C().miniPeriodic(grid, {});
|
||||
var legend = document.createElement('div'); legend.className = 'pt-legend';
|
||||
legend.innerHTML = '<span><i style="background:rgba(13,148,136,.4)"></i>металлы</span><span><i style="background:rgba(245,158,11,.5)"></i>неметаллы</span><span><i style="background:rgba(124,58,237,.4)"></i>металлоиды</span><span><i style="background:rgba(37,99,235,.4)"></i>инертные</span>';
|
||||
el.appendChild(legend);
|
||||
}
|
||||
|
||||
function mount_p24() {
|
||||
periodicModes('c-pt-metals', [
|
||||
{ k: 'metals', l: 'Металлы' }, { k: 'nonmetals', l: 'Неметаллы' }, { k: 'metalloids', l: 'Металлоиды' }, { k: null, l: 'Сброс' }
|
||||
]);
|
||||
}
|
||||
function mount_p26() {
|
||||
periodicModes('c-pt-fam', [
|
||||
{ k: 'alkali', l: 'Щелочные' }, { k: 'alkaline', l: 'Щёлочноземельные' }, { k: 'halogens', l: 'Галогены' }, { k: 'noble', l: 'Инертные газы' }, { k: null, l: 'Сброс' }
|
||||
]);
|
||||
}
|
||||
function mount_p28() {
|
||||
periodicModes('c-pt-struct', [
|
||||
{ k: { period: 2 }, l: 'Период 2' }, { k: { period: 3 }, l: 'Период 3' }, { k: { group: 1 }, l: 'Группа I' }, { k: { group: 17 }, l: 'Группа VII' }, { k: null, l: 'Сброс' }
|
||||
]);
|
||||
}
|
||||
|
||||
/* §25 — амфотерность: Zn(OH)₂ растворяется и в кислоте, и в щёлочи */
|
||||
function mount_p25() {
|
||||
var el = $('c-amph'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML =
|
||||
'<div class="amph-row">' +
|
||||
'<button class="btn amph-btn" data-r="acid">+ кислота (HCl)</button>' +
|
||||
'<button class="btn amph-btn" data-r="base">+ щёлочь (NaOH)</button>' +
|
||||
'<button class="btn amph-reset">Сначала</button>' +
|
||||
'</div>' +
|
||||
'<div class="amph-stage"></div>' +
|
||||
'<div class="out amph-out">Zn(OH)₂ — амфотерный гидроксид. Добавь кислоту или щёлочь и посмотри, что будет.</div>';
|
||||
var stage = el.querySelector('.amph-stage'), out = el.querySelector('.amph-out');
|
||||
function tt(o) { return C().testTube ? C().testTube(o) : ''; }
|
||||
function reset() { stage.innerHTML = '<div style="text-align:center;color:#0f766e">' + tt({ color: '#fff', precipitate: '#cbd5e1', label: 'Zn(OH)2' }) + '</div><div class="tt-cap" style="margin:0 auto">Белый осадок Zn(OH)₂</div>'; out.className = 'out amph-out'; out.innerHTML = 'Zn(OH)₂ — белый студенистый осадок (амфотерный гидроксид).'; }
|
||||
el.querySelectorAll('.amph-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var r = b.getAttribute('data-r');
|
||||
stage.innerHTML = '<div style="text-align:center;color:#0f766e">' + tt({ color: '#dbeafe' }) + '</div><div class="tt-cap" style="margin:0 auto">Осадок растворился</div>';
|
||||
out.className = 'out amph-out ok';
|
||||
out.innerHTML = r === 'acid'
|
||||
? 'Как <b>основание</b>: Zn(OH)₂ + 2HCl → ZnCl₂ + 2H₂O — осадок растворился.'
|
||||
: 'Как <b>кислота</b>: Zn(OH)₂ + 2NaOH → Na₂[Zn(OH)₄] — осадок растворился (амфотерность!).';
|
||||
});
|
||||
});
|
||||
el.querySelector('.amph-reset').addEventListener('click', reset);
|
||||
reset();
|
||||
}
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"per","t":"Период","x":20,"y":22},{"id":"grp","t":"Группа","x":20,"y":95},{"id":"fam","t":"Семейство","x":160,"y":55},{"id":"prop","t":"Свойства","x":330,"y":55}],"edges":[{"f":"per","t":"prop","label":"номер периода = число слоёв"},{"f":"grp","t":"prop","label":"номер группы = внешние e⁻"},{"f":"fam","t":"grp","label":"одна группа — одно семейство"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p25: mount_p25 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p24: mount_p24, p26: mount_p26, p28: mount_p28 };
|
||||
})(window);
|
||||
@@ -0,0 +1,98 @@
|
||||
/* chem8_ch3_widgets.js — виджеты Главы 3 «Строение атома».
|
||||
* Использует window.Chem8: atomShell, shellConfig, nuclide, zSym, miniPeriodic, arOf.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
/* §29 — модель атома */
|
||||
function mount_p29() { var el = $('c-atom'); if (el && !el._b && C().atomShell) { el._b = 1; C().atomShell(el, { z: 11 }); } }
|
||||
|
||||
/* §30 — нуклид: A = Z + N */
|
||||
function mount_p30() {
|
||||
var el = $('c-nuclide'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML = '<div class="fld"><label>Z (протоны)</label><input type="number" id="nz" value="6" min="1" max="100" style="width:80px"><label>A (масс. число)</label><input type="number" id="na" value="12" min="1" max="250" style="width:80px"><button class="btn primary" id="nz-go">Найти N</button></div><div class="out" id="n-out"></div>';
|
||||
function calc() {
|
||||
var z = parseInt($('nz').value, 10), a = parseInt($('na').value, 10);
|
||||
if (isNaN(z) || isNaN(a) || a < z) { $('n-out').className = 'out bad'; $('n-out').textContent = 'Проверь: A не может быть меньше Z.'; return; }
|
||||
var nu = C().nuclide(z, a);
|
||||
$('n-out').className = 'out ok';
|
||||
$('n-out').innerHTML = '<span class="bd">Элемент: <b>' + nu.sym + '</b><br>Протонов Z = ' + z + '<br>Нейтронов N = A − Z = ' + a + ' − ' + z + ' = <b>' + nu.N + '</b><br>Нуклид: ' + nu.sym + '-' + a + '</span>';
|
||||
}
|
||||
$('nz-go').addEventListener('click', calc); calc();
|
||||
}
|
||||
|
||||
/* §31 — средняя Ar по изотопам */
|
||||
function mount_p31() {
|
||||
var el = $('c-iso'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML = '<div class="fld"><label>Изотоп 1: масса</label><input type="number" id="im1" value="35" style="width:70px"><label>доля, %</label><input type="number" id="ip1" value="75" style="width:70px"></div>'
|
||||
+ '<div class="fld"><label>Изотоп 2: масса</label><input type="number" id="im2" value="37" style="width:70px"><label>доля, %</label><input type="number" id="ip2" value="25" style="width:70px"><button class="btn primary" id="iso-go">Средняя A_r</button></div><div class="out" id="iso-out">Пример: хлор — смесь ³⁵Cl (75%) и ³⁷Cl (25%).</div>';
|
||||
function calc() {
|
||||
var m1 = parseFloat($('im1').value), p1 = parseFloat($('ip1').value), m2 = parseFloat($('im2').value), p2 = parseFloat($('ip2').value);
|
||||
if ([m1, p1, m2, p2].some(isNaN)) { $('iso-out').className = 'out bad'; $('iso-out').textContent = 'Введите все значения.'; return; }
|
||||
var ar = (m1 * p1 + m2 * p2) / (p1 + p2);
|
||||
$('iso-out').className = 'out ok';
|
||||
$('iso-out').innerHTML = '<span class="bd">A_r = (' + m1 + '·' + p1 + ' + ' + m2 + '·' + p2 + ') / 100 = <b>' + (Math.round(ar * 100) / 100).toString().replace('.', ',') + '</b></span>';
|
||||
}
|
||||
$('iso-go').addEventListener('click', calc); calc();
|
||||
}
|
||||
|
||||
/* §33 — строение электронных оболочек (та же модель, акцент на слои) */
|
||||
function mount_p33() { var el = $('c-shells'); if (el && !el._b && C().atomShell) { el._b = 1; C().atomShell(el, { z: 17 }); } }
|
||||
|
||||
/* §34 — периодичность: ПСХЭ с подсветкой периодов/групп */
|
||||
function mount_p34() {
|
||||
var el = $('c-trend'); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
|
||||
var modes = [{ k: { period: 2 }, l: 'Период 2 →' }, { k: { period: 3 }, l: 'Период 3 →' }, { k: { group: 1 }, l: 'Группа I ↓' }, { k: { group: 17 }, l: 'Группа VII ↓' }, { k: null, l: 'Сброс' }];
|
||||
var bar = document.createElement('div'); bar.className = 'pt-modes';
|
||||
var grid = document.createElement('div'), note = document.createElement('div'); note.className = 'out';
|
||||
var TXT = {
|
||||
'p2': 'По периоду слева направо: радиус атома уменьшается, металлические свойства ослабевают, неметаллические — усиливаются.',
|
||||
'p3': 'То же в 3-м периоде: от активного металла Na к активному неметаллу Cl.',
|
||||
'g1': 'Вниз по группе: радиус растёт, металлические свойства усиливаются (Li → Na → K → ...).',
|
||||
'g17': 'Вниз по группе галогенов: неметаллические свойства ослабевают (F самый активный).'
|
||||
};
|
||||
modes.forEach(function (m) {
|
||||
var b = document.createElement('button'); b.className = 'btn'; b.textContent = m.l;
|
||||
b.addEventListener('click', function () {
|
||||
bar.querySelectorAll('.btn').forEach(function (x) { x.classList.remove('primary'); }); b.classList.add('primary');
|
||||
if (api) api.highlight(m.k);
|
||||
var key = m.k ? (m.k.period ? 'p' + m.k.period : 'g' + m.k.group) : null;
|
||||
note.textContent = key && TXT[key] ? TXT[key] : 'Выбери период или группу — увидишь тренд свойств.';
|
||||
});
|
||||
bar.appendChild(b);
|
||||
});
|
||||
el.appendChild(bar); el.appendChild(grid); el.appendChild(note);
|
||||
var api = C().miniPeriodic(grid, {});
|
||||
note.textContent = 'Выбери период или группу — увидишь, как меняются свойства.';
|
||||
}
|
||||
|
||||
/* §35 — паспорт элемента: клик в ПСХЭ → полная характеристика */
|
||||
function mount_p35() {
|
||||
var el = $('c-passport'); if (!el || el._b || !C().miniPeriodic) return; el._b = 1;
|
||||
var grid = document.createElement('div'), panel = document.createElement('div'); panel.className = 'passport';
|
||||
panel.innerHTML = '<h4>Паспорт элемента</h4><div style="color:var(--muted);font-size:.85rem">Кликни элемент в системе.</div>';
|
||||
el.appendChild(grid); el.appendChild(panel);
|
||||
C().miniPeriodic(grid, { onClick: function (sym, info) {
|
||||
var sh = C().shellConfig(info.z);
|
||||
var catRu = info.cat === 'metal' ? 'металл' : info.cat === 'nonmetal' ? 'неметалл' : info.cat === 'metalloid' ? 'металлоид' : 'инертный газ';
|
||||
panel.innerHTML = '<h4>Паспорт: ' + sym + '</h4><div class="passport-grid">'
|
||||
+ '<div><b>Z</b>: ' + info.z + '</div>'
|
||||
+ '<div><b>A_r</b>: ' + (info.ar || '—') + '</div>'
|
||||
+ '<div><b>Период</b>: ' + info.p + '</div>'
|
||||
+ '<div><b>Группа</b>: ' + info.g + '</div>'
|
||||
+ '<div><b>Тип</b>: ' + catRu + '</div>'
|
||||
+ '<div><b>Протонов</b>: ' + info.z + '</div>'
|
||||
+ '<div><b>Электронов</b>: ' + info.z + '</div>'
|
||||
+ '<div><b>Слои e⁻</b>: ' + sh.join(' ) ') + '</div>'
|
||||
+ '<div><b>Внешних e⁻</b>: ' + sh[sh.length - 1] + '</div>'
|
||||
+ '</div>';
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(panel); } catch (e) {}
|
||||
} });
|
||||
}
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"nuc","t":"Ядро","x":20,"y":55},{"id":"prot","t":"Протоны","x":170,"y":22},{"id":"neut","t":"Нейтроны","x":170,"y":95},{"id":"elec","t":"Электроны","x":330,"y":55}],"edges":[{"f":"nuc","t":"prot","label":"Z = число протонов"},{"f":"nuc","t":"neut","label":"N нейтронов (A = Z + N)"},{"f":"prot","t":"elec","label":"Z = e⁻ (атом нейтрален)"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p29: mount_p29, p30: mount_p30, p31: mount_p31, p33: mount_p33 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p34: mount_p34, p35: mount_p35 };
|
||||
})(window);
|
||||
@@ -0,0 +1,20 @@
|
||||
/* chem8_ch4_widgets.js — виджеты Главы 4 «Химическая связь».
|
||||
* Использует window.Chem8: bondType.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function M() { return W.Chem8Mol; }
|
||||
function mount_p37() { var el = $('c-bond1'); if (el && !el._b && C().bondType) { el._b = 1; C().bondType(el, { a: 'H', b: 'H' }); } }
|
||||
function mount_p38() {
|
||||
var el = $('c-bond2'); if (el && !el._b && C().bondType) { el._b = 1; C().bondType(el, { a: 'H', b: 'Cl' }); }
|
||||
var mol = $('c-mol'); if (mol && !mol._b && M()) { mol._b = 1; M().molModel(mol, 'H2O'); }
|
||||
}
|
||||
function mount_p41() { var el = $('c-lattice'); if (el && !el._b && M()) { el._b = 1; M().latticeViewer(el, 'ionic'); } }
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"cov","t":"Ковалент.","x":20,"y":22},{"id":"ion","t":"Ионная","x":20,"y":95},{"id":"met","t":"Металлич.","x":160,"y":55},{"id":"lat","t":"Решётка","x":330,"y":22},{"id":"prop","t":"Свойства","x":330,"y":95}],"edges":[{"f":"cov","t":"lat","label":"ковалентная → атомная/молек. решётка"},{"f":"ion","t":"lat","label":"ионная → ионная решётка"},{"f":"met","t":"lat","label":"металлическая → металл. решётка"},{"f":"lat","t":"prop","label":"тип решётки определяет свойства"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = {};
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p37: mount_p37, p38: mount_p38, p41: mount_p41 };
|
||||
})(window);
|
||||
@@ -0,0 +1,59 @@
|
||||
/* chem8_ch5_widgets.js — виджеты Главы 5 «Окислительно-восстановительные реакции».
|
||||
* Использует window.Chem8: oxStateCalc.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
/* §42 — калькулятор степени окисления */
|
||||
function mount_p42() { var el = $('c-ox'); if (el && !el._b && C().oxStateCalc) { el._b = 1; C().oxStateCalc(el, { formula: 'H2SO4' }); } }
|
||||
|
||||
/* §44 — пошаговый электронный баланс (преднабор) */
|
||||
var R = [
|
||||
{ eq: '2Mg + O₂ → 2MgO',
|
||||
steps: [
|
||||
'Степени окисления: Mg⁰, O₂⁰ → Mg⁺², O⁻².',
|
||||
'Mg⁰ − 2e⁻ → Mg⁺² — окисление (Mg — восстановитель).',
|
||||
'O₂⁰ + 4e⁻ → 2O⁻² — восстановление (O₂ — окислитель).',
|
||||
'Электронный баланс: отдано 2e⁻ (×2 = 4), принято 4e⁻ → множители 2 и 1.',
|
||||
'Коэффициенты: 2Mg + O₂ → 2MgO. ✓'
|
||||
] },
|
||||
{ eq: 'Fe + CuSO₄ → FeSO₄ + Cu',
|
||||
steps: [
|
||||
'Меняют с.о. только Fe и Cu: Fe⁰ → Fe⁺², Cu⁺² → Cu⁰.',
|
||||
'Fe⁰ − 2e⁻ → Fe⁺² — окисление (Fe — восстановитель).',
|
||||
'Cu⁺² + 2e⁻ → Cu⁰ — восстановление (Cu⁺² — окислитель).',
|
||||
'Отдано 2e⁻ = принято 2e⁻ → множители 1 и 1.',
|
||||
'Коэффициенты: Fe + CuSO₄ → FeSO₄ + Cu. ✓'
|
||||
] },
|
||||
{ eq: '2Na + Cl₂ → 2NaCl',
|
||||
steps: [
|
||||
'Na⁰ и Cl₂⁰ → Na⁺ и Cl⁻.',
|
||||
'Na⁰ − 1e⁻ → Na⁺ — окисление (Na — восстановитель).',
|
||||
'Cl₂⁰ + 2e⁻ → 2Cl⁻ — восстановление (Cl₂ — окислитель).',
|
||||
'Баланс: 1e⁻ ×2 = 2e⁻ → множители 2 и 1.',
|
||||
'Коэффициенты: 2Na + Cl₂ → 2NaCl. ✓'
|
||||
] }
|
||||
];
|
||||
function mount_p44() {
|
||||
var pick = $('c-redox-pick'), out = $('c-redox-out'), bStep = $('c-redox-step'), bAll = $('c-redox-all'); if (!pick || pick._b) return; pick._b = 1;
|
||||
R.forEach(function (p, i) { var o = document.createElement('option'); o.value = i; o.textContent = p.eq; pick.appendChild(o); });
|
||||
var cur = 0, shown = 0;
|
||||
function render() {
|
||||
var p = R[cur];
|
||||
var html = '<b>' + p.eq + '</b><div style="margin-top:8px">';
|
||||
for (var i = 0; i < shown; i++) html += '<div class="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
|
||||
if (shown === 0) html += '<span style="color:var(--muted)">Нажимай «Следующий шаг» — разберём метод электронного баланса.</span>';
|
||||
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
|
||||
}
|
||||
pick.addEventListener('change', function () { cur = +pick.value; shown = 0; render(); });
|
||||
bStep.addEventListener('click', function () { if (shown < R[cur].steps.length) { shown++; render(); } });
|
||||
bAll.addEventListener('click', function () { shown = R[cur].steps.length; render(); });
|
||||
render();
|
||||
}
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"so","t":"Степ. ок.","x":20,"y":55},{"id":"oxi","t":"Окисление","x":170,"y":22},{"id":"red","t":"Восстан.","x":170,"y":95},{"id":"bal","t":"Баланс","x":330,"y":55,"c":"#d97706"}],"edges":[{"f":"so","t":"oxi","label":"с.о. растёт (отдача e⁻)"},{"f":"so","t":"red","label":"с.о. падает (приём e⁻)"},{"f":"oxi","t":"bal","label":"отдано e⁻"},{"f":"red","t":"bal","label":"= принято e⁻"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p42: mount_p42 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p44: mount_p44 };
|
||||
})(window);
|
||||
@@ -0,0 +1,85 @@
|
||||
/* chem8_ch6_widgets.js — виджеты Главы 6 «Растворы».
|
||||
* Использует window.Chem8: classifier, solubilityTable, molarMass.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function rr(v, d) { var p = Math.pow(10, d == null ? 2 : d); return (Math.round(v * p) / p).toString().replace('.', ','); }
|
||||
|
||||
/* §46 — классификатор смесей */
|
||||
function mount_p46() {
|
||||
var el = $('c-mix'); if (!el || el._b || !C().classifier) return; el._b = 1;
|
||||
C().classifier(el, {
|
||||
items: [
|
||||
{ id: 'air', label: 'воздух', cat: 'odn' }, { id: 'saltsol', label: 'раствор соли', cat: 'odn' }, { id: 'steel', label: 'сталь', cat: 'odn' },
|
||||
{ id: 'sandwater', label: 'песок + вода', cat: 'neod' }, { id: 'milk', label: 'молоко', cat: 'neod' }, { id: 'granite', label: 'гранит', cat: 'neod' }
|
||||
],
|
||||
buckets: [{ cat: 'odn', label: 'Однородные (растворы)' }, { cat: 'neod', label: 'Неоднородные' }],
|
||||
onCheck: function (ok) { if (ok && W.addXp) W.addXp(8, 'p46-mix'); }
|
||||
});
|
||||
}
|
||||
|
||||
/* §48 — кривая растворимости s = f(t) */
|
||||
var CURVE = { KNO3: [13, 21, 32, 46, 64, 88, 110, 138, 169, 202, 246], NaCl: [35.7, 35.8, 36, 36.3, 36.6, 37, 37.3, 37.8, 38.4, 39, 39.8] };
|
||||
function mount_p48() {
|
||||
var el = $('c-solcurve'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML = '<div class="fld"><label>Вещество</label><select id="sc-sub"><option value="KNO3">KNO₃ (нитрат калия)</option><option value="NaCl">NaCl (соль)</option></select><label>t, °C</label><input type="range" id="sc-t" min="0" max="100" step="10" value="40"><span class="bd" id="sc-tv"></span></div><div id="sc-plot"></div><div class="out" id="sc-out"></div>';
|
||||
var sub = $('sc-sub'), tr = $('sc-t'), tv = $('sc-tv'), plot = $('sc-plot'), out = $('sc-out');
|
||||
function draw() {
|
||||
var data = CURVE[sub.value], t = +tr.value, idx = t / 10, s = data[idx];
|
||||
tv.textContent = t + ' °C';
|
||||
var maxS = Math.max.apply(null, CURVE.KNO3);
|
||||
var W0 = 280, H0 = 140, pad = 24;
|
||||
var pts = data.map(function (v, i) { var x = pad + i / 10 * (W0 - pad * 2); var y = H0 - pad - v / maxS * (H0 - pad * 2); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' ');
|
||||
var cx = pad + idx / 10 * (W0 - pad * 2), cy = H0 - pad - s / maxS * (H0 - pad * 2);
|
||||
plot.innerHTML = '<svg viewBox="0 0 ' + W0 + ' ' + H0 + '" style="width:100%;max-width:340px;color:var(--pri)">'
|
||||
+ '<line x1="' + pad + '" y1="' + (H0 - pad) + '" x2="' + (W0 - pad) + '" y2="' + (H0 - pad) + '" stroke="currentColor" opacity=".4"/>'
|
||||
+ '<line x1="' + pad + '" y1="' + pad + '" x2="' + pad + '" y2="' + (H0 - pad) + '" stroke="currentColor" opacity=".4"/>'
|
||||
+ '<polyline points="' + pts + '" fill="none" stroke="var(--pri)" stroke-width="2"/>'
|
||||
+ '<circle cx="' + cx.toFixed(1) + '" cy="' + cy.toFixed(1) + '" r="5" fill="var(--pri)"/>'
|
||||
+ '<text x="' + (W0 - pad) + '" y="' + (H0 - 6) + '" text-anchor="end" font-size="9" fill="currentColor">t, °C</text>'
|
||||
+ '<text x="' + pad + '" y="' + (pad - 8) + '" font-size="9" fill="currentColor">s, г/100г</text>'
|
||||
+ '</svg>';
|
||||
out.className = 'out ok';
|
||||
out.innerHTML = '<span class="bd">При ' + t + ' °C растворимость ' + sub.value + ' ≈ <b>' + rr(s, 1) + ' г</b> на 100 г воды.' + (sub.value === 'KNO3' ? ' Растворимость сильно растёт с температурой.' : ' У NaCl почти не зависит от t.') + '</span>';
|
||||
}
|
||||
sub.addEventListener('change', draw); tr.addEventListener('input', draw); draw();
|
||||
}
|
||||
|
||||
/* §50 — массовая доля w */
|
||||
function mount_p50() {
|
||||
var el = $('c-wcalc'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML = '<div class="fld"><label>m(вещества), г</label><input type="number" id="w-ms" value="20" style="width:80px"><label>m(воды), г</label><input type="number" id="w-mw" value="80" style="width:80px"><button class="btn primary" id="w-go">Найти w</button></div><div class="out" id="w-out"></div>';
|
||||
function calc() {
|
||||
var ms = parseFloat($('w-ms').value), mw = parseFloat($('w-mw').value);
|
||||
if (isNaN(ms) || isNaN(mw) || ms + mw <= 0) { $('w-out').className = 'out bad'; $('w-out').textContent = 'Введите массы.'; return; }
|
||||
var w = ms / (ms + mw) * 100;
|
||||
$('w-out').className = 'out ok';
|
||||
$('w-out').innerHTML = '<span class="bd">m(раствора) = ' + ms + ' + ' + mw + ' = ' + (ms + mw) + ' г<br>w = m(в-ва)/m(р-ра) = ' + ms + '/' + (ms + mw) + ' = <b>' + rr(w, 1) + ' %</b></span>';
|
||||
}
|
||||
$('w-go').addEventListener('click', calc); calc();
|
||||
}
|
||||
|
||||
/* §51 — молярная концентрация c = n/V */
|
||||
function mount_p51() {
|
||||
var el = $('c-ccalc'); if (!el || el._b) return; el._b = 1;
|
||||
el.innerHTML = '<div class="fld"><label>Вещество</label><input type="text" id="c-f" value="NaOH" style="width:110px;font-family:var(--mono)"><label>m, г</label><input type="number" id="c-m" value="40" style="width:70px"><label>V, л</label><input type="number" id="c-v" value="1" step="0.1" style="width:70px"><button class="btn primary" id="c-go">Найти c</button></div><div class="out" id="c-out"></div>';
|
||||
function calc() {
|
||||
var f = $('c-f').value.trim(), M = C().molarMass ? C().molarMass(f) : NaN, m = parseFloat($('c-m').value), V = parseFloat($('c-v').value);
|
||||
if (isNaN(M)) { $('c-out').className = 'out bad'; $('c-out').textContent = 'Не удалось разобрать формулу.'; return; }
|
||||
if (isNaN(m) || isNaN(V) || V <= 0) { $('c-out').className = 'out bad'; $('c-out').textContent = 'Введите m и V.'; return; }
|
||||
var n = m / M, c = n / V;
|
||||
$('c-out').className = 'out ok';
|
||||
$('c-out').innerHTML = '<span class="bd">M(' + f + ') = ' + M + ' г/моль<br>n = m/M = ' + m + '/' + M + ' = ' + rr(n) + ' моль<br>c = n/V = ' + rr(n) + '/' + rr(V) + ' = <b>' + rr(c) + ' моль/л</b></span>';
|
||||
}
|
||||
$('c-go').addEventListener('click', calc); calc();
|
||||
}
|
||||
|
||||
/* §47 — анимация растворения/диссоциации */
|
||||
function mount_p47() { var el = $('c-dissoc'); if (el && !el._b && C().dissociationAnim) { el._b = 1; C().dissociationAnim(el, { substance: 'NaCl' }); } }
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"mix","t":"Смесь","x":20,"y":55},{"id":"sol","t":"Раствор","x":170,"y":55,"c":"#0891b2"},{"id":"sb","t":"Растворим.","x":330,"y":22},{"id":"w","t":"w (доля)","x":330,"y":55},{"id":"c","t":"c (моль/л)","x":330,"y":95}],"edges":[{"f":"mix","t":"sol","label":"однородная смесь = раствор"},{"f":"sol","t":"sb","label":"растворимость: г / 100 г воды"},{"f":"sol","t":"w","label":"массовая доля w = m / m"},{"f":"sol","t":"c","label":"молярная концентрация c = n/V"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p46: mount_p46, p50: mount_p50, p51: mount_p51 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p47: mount_p47, p48: mount_p48 };
|
||||
})(window);
|
||||
@@ -0,0 +1,430 @@
|
||||
/* chem8_engine.js — общий движок интерактивных учебников «Химия 8».
|
||||
*
|
||||
* Воспроизводит каркас учебников физики: SPA с para-selector, ленивая сборка §,
|
||||
* карточки теории (makeCard), тренажёр задач (числовой ввод + MCQ), sidebar-шпаргалка,
|
||||
* прогресс/XP/уровни/достижения, серверная синхронизация прогресса, тема.
|
||||
*
|
||||
* Страница главы ОБЪЯВЛЯЕТ данные (до загрузки движка, инлайн-скриптом):
|
||||
* window.CHEM8_CFG = { slug, themeKey, xpKey, progKey, achKey, hubHref }
|
||||
* window.PARAS = [{id, num, name, sub, final?}]
|
||||
* window.BUILDERS = { p1: ()=>build_p1(), ... } // наполняют #<id>-body
|
||||
* window.POOLS = { p1: [task,...], ... } // task: {q,hint,unit,a,ex,tol} | {q,opts,a,ex}
|
||||
* window.SIDEBARS = { p1: {title, rows:[[k,v],...]}, ... }
|
||||
* window.TIPS = [{sec, html}, ...]
|
||||
* window.CHEM8_WIDGETS = { p1: ()=>add_p1(), ... } // монтаж виджетов §
|
||||
* window.FLAG_MOUNTS = { p6: ()=>mountFlag('p6'), ... } // флагман-интерактивы
|
||||
* window.ACH_LABELS = { start, p1_done, ... }
|
||||
*
|
||||
* Движок ЭКСПОРТИРУЕТ на window: goTo, checkNum, selectMcq, nextTask, goToTask,
|
||||
* resetTasks, makeCard, secNav, readButton, addXp, achievement, bumpProgress.
|
||||
* Инициализация — на DOMContentLoaded.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
|
||||
// Конфиг резолвится лениво в init() — страница задаёт window.CHEM8_CFG
|
||||
// в body-скрипте, который при defer выполняется до движка, но не полагаемся на это.
|
||||
var CFG = {}, SLUG = 'chemistry-8';
|
||||
var K = { theme: 'chemistry8_theme', xp: 'chemistry8_xp', prog: 'chemistry-8_progress', ach: 'chemistry-8_ach' };
|
||||
function resolveCfg() {
|
||||
CFG = W.CHEM8_CFG || {};
|
||||
SLUG = CFG.slug || 'chemistry-8';
|
||||
K = {
|
||||
theme: CFG.themeKey || 'chemistry8_theme',
|
||||
xp: CFG.xpKey || 'chemistry8_xp',
|
||||
prog: CFG.progKey || (SLUG + '_progress'),
|
||||
ach: CFG.achKey || (SLUG + '_ach')
|
||||
};
|
||||
}
|
||||
function PARAS() { return W.PARAS || []; }
|
||||
function POOLS() { return W.POOLS || {}; }
|
||||
function BUILDERS(){ return W.BUILDERS || {}; }
|
||||
function ACHL() { return W.ACH_LABELS || {}; }
|
||||
|
||||
var STATE = { current: null, progress: {}, achievements: new Map(), xp: 0, level: 1 };
|
||||
var SEC = {}; // STATE задач по секциям
|
||||
|
||||
/* ── XP / уровни ───────────────────────────────────────────────── */
|
||||
function calcLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
|
||||
function xpForLevel(lv) { return (lv - 1) * (lv - 1) * 100; }
|
||||
|
||||
function loadProgress() {
|
||||
try {
|
||||
var s = localStorage.getItem(K.prog); if (s) Object.assign(STATE.progress, JSON.parse(s));
|
||||
var a = localStorage.getItem(K.ach);
|
||||
if (a) { var p = JSON.parse(a); if (p && typeof p === 'object') for (var id in p) STATE.achievements.set(id, p[id]); }
|
||||
STATE.xp = parseInt(localStorage.getItem(K.xp) || '0', 10) || 0;
|
||||
STATE.level = calcLevel(STATE.xp);
|
||||
} catch (e) {}
|
||||
}
|
||||
function saveProgress() {
|
||||
try {
|
||||
localStorage.setItem(K.prog, JSON.stringify(STATE.progress));
|
||||
localStorage.setItem(K.ach, JSON.stringify(mapToObj(STATE.achievements)));
|
||||
localStorage.setItem(K.xp, String(STATE.xp));
|
||||
} catch (e) {}
|
||||
}
|
||||
function mapToObj(m) { var o = {}; m.forEach(function (v, k) { o[k] = v; }); return o; }
|
||||
|
||||
function addXp(n, src) {
|
||||
if (!n) return;
|
||||
var prev = STATE.level;
|
||||
STATE.xp = Math.max(0, (STATE.xp || 0) + n); STATE.level = calcLevel(STATE.xp);
|
||||
saveProgress(); refreshUI();
|
||||
try { if (W.LS && W.LS.xp && W.LS.xp.add) W.LS.xp.add(n, SLUG + '-' + (src || 'x')); } catch (e) {}
|
||||
if (STATE.level > prev) popup('Уровень ' + STATE.level + '!');
|
||||
}
|
||||
function bumpProgress(key, delta) {
|
||||
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key] || 0) + delta));
|
||||
saveProgress(); refreshUI();
|
||||
if (STATE.progress[key] >= 50) markServerRead(key);
|
||||
}
|
||||
function achievement(id, text) {
|
||||
if (STATE.achievements.has(id)) return;
|
||||
var label = text || ACHL()[id] || id;
|
||||
STATE.achievements.set(id, label); saveProgress();
|
||||
popup(label, true);
|
||||
addXp(20, 'ach-' + id);
|
||||
}
|
||||
|
||||
/* ── серверная синхронизация ───────────────────────────────────── */
|
||||
var _marked = {}, _pending = null, _timer = null;
|
||||
function _flush() {
|
||||
var body = _pending; _pending = null; if (!body) return;
|
||||
var tok = (W.LS && W.LS.getToken) ? W.LS.getToken() : ''; if (!tok) return;
|
||||
fetch('/api/textbooks/' + SLUG + '/progress', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok },
|
||||
body: JSON.stringify(body), keepalive: true
|
||||
}).catch(function () {});
|
||||
}
|
||||
function _queue(p) { _pending = Object.assign(_pending || {}, p); if (_timer) clearTimeout(_timer); _timer = setTimeout(_flush, 600); }
|
||||
function markServerRead(id) { if (_marked[id] || /^final/.test(id)) return; _marked[id] = 1; _queue({ mark_read: id }); }
|
||||
function markLastPara(id) { _queue({ last_para: id }); }
|
||||
function loadServerReadState() {
|
||||
var tok = (W.LS && W.LS.getToken) ? W.LS.getToken() : ''; if (!tok) return;
|
||||
fetch('/api/textbooks/' + SLUG, { headers: { 'Authorization': 'Bearer ' + tok } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d || !d.progress || !d.progress.read) return;
|
||||
d.progress.read.forEach(function (k) { _marked[k] = 1; if ((STATE.progress[k] || 0) < 50) STATE.progress[k] = 100; });
|
||||
saveProgress(); refreshUI();
|
||||
}).catch(function () {});
|
||||
}
|
||||
W.addEventListener('beforeunload', _flush);
|
||||
|
||||
/* ── popup ачивки / уровня ─────────────────────────────────────── */
|
||||
function popup(text, gold) {
|
||||
var pop = document.getElementById('ach-popup'); if (!pop) return;
|
||||
var t = document.getElementById('ach-text'); if (t) t.textContent = text;
|
||||
pop.classList.toggle('gold', !!gold);
|
||||
pop.classList.add('show'); setTimeout(function () { pop.classList.remove('show'); }, 3000);
|
||||
if (gold) { try { if (W.confetti) W.confetti({ particleCount: 160, spread: 95, origin: { y: .65 } }); } catch (e) {} }
|
||||
}
|
||||
|
||||
/* ── para-selector + hero ──────────────────────────────────────── */
|
||||
function buildParaSelector() {
|
||||
var g = document.getElementById('psel-grid'); if (!g) return;
|
||||
g.innerHTML = '';
|
||||
PARAS().forEach(function (p) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'psel-card' + (p.final ? ' final' : '');
|
||||
card.dataset.id = p.id; card.dataset.progCard = p.id;
|
||||
card.innerHTML = '<div class="psel-num">' + p.num + '</div><div class="psel-name">' + p.name + '</div>'
|
||||
+ (p.sub ? '<div class="psel-sub">' + p.sub + '</div>' : '')
|
||||
+ '<div class="psel-prog"><div class="psel-prog-fill"></div></div>'
|
||||
+ '<span class="psel-done"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>';
|
||||
card.addEventListener('click', function () { goTo(p.id); });
|
||||
g.appendChild(card);
|
||||
});
|
||||
if (W.renderMathInElement) try { renderMath(g); } catch (e) {}
|
||||
}
|
||||
|
||||
function refreshUI() {
|
||||
var total = PARAS().length || 1;
|
||||
var sum = 0; PARAS().forEach(function (p) { sum += (STATE.progress[p.id] || 0); });
|
||||
var pct = Math.round(sum / total);
|
||||
var hf = document.getElementById('hero-hp-fill'); if (hf) hf.style.width = pct + '%';
|
||||
var ht = document.getElementById('hero-hp-text'); if (ht) ht.textContent = pct + '%';
|
||||
var xb = document.getElementById('hero-xp-badge');
|
||||
if (xb) xb.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. ' + STATE.level + ' \xb7 ' + (STATE.xp || 0) + ' XP';
|
||||
document.querySelectorAll('.psel-card').forEach(function (c) {
|
||||
var id = c.dataset.id; var pp = STATE.progress[id] || 0;
|
||||
var fl = c.querySelector('.psel-prog-fill'); if (fl) fl.style.width = pp + '%';
|
||||
c.classList.toggle('done', pp >= 50);
|
||||
});
|
||||
if (STATE.current && document.getElementById('sidebar-content')) { try { buildSidebar(STATE.current); } catch (e) {} }
|
||||
}
|
||||
|
||||
/* ── ленивая сборка § + инъекция задач ─────────────────────────── */
|
||||
var BUILT = {};
|
||||
function ensureBuilt(id) {
|
||||
if (BUILT[id]) return;
|
||||
var fn = BUILDERS()[id];
|
||||
if (fn) { try { fn(); } catch (e) { if (W.console) console.warn('build ' + id, e.message); } BUILT[id] = 1; }
|
||||
_injectTasks(id);
|
||||
_mountWidgets(id);
|
||||
}
|
||||
function _mountWidgets(id) {
|
||||
setTimeout(function () {
|
||||
try { if (W.CHEM8_WIDGETS && W.CHEM8_WIDGETS[id]) W.CHEM8_WIDGETS[id](); } catch (e) { if (W.console) console.warn('widget ' + id, e.message); }
|
||||
try { if (W.FLAG_MOUNTS && W.FLAG_MOUNTS[id]) W.FLAG_MOUNTS[id](); } catch (e) { if (W.console) console.warn('flag ' + id, e.message); }
|
||||
}, 40);
|
||||
}
|
||||
function _makeTaskBlock(sec) {
|
||||
return '<div class="legacy-tasks" id="ptab-' + sec + '">'
|
||||
+ '<div class="lt-head"><span class="lt-title">Задачи параграфа</span>'
|
||||
+ '<span class="chip chip-ok"><span id="ok' + sec + '">0</span> верно</span>'
|
||||
+ '<span class="chip chip-tot"><span id="cur' + sec + '">0</span>/<span id="max' + sec + '">?</span></span>'
|
||||
+ '<button class="btn lt-reset" onclick="resetTasks(\'' + sec + '\')">Заново</button></div>'
|
||||
+ '<div class="prog-wrap"><div id="prog' + sec + '" class="prog-fill"></div></div>'
|
||||
+ '<div class="nav-dots" id="navDots' + sec + '"></div>'
|
||||
+ '<div id="taskArea' + sec + '"></div>'
|
||||
+ '<div class="feedback" id="fb' + sec + '"></div>'
|
||||
+ '<div class="lt-foot"><button class="btn primary" id="nextBtn' + sec + '" onclick="nextTask(\'' + sec + '\')" style="display:none">Следующая →</button></div>'
|
||||
+ '<div class="summary" id="sum' + sec + '"><div class="sum-t">Параграф пройден!</div><div class="big-score" id="sumScore' + sec + '"></div><div class="sum-grade" id="sumGrade' + sec + '"></div></div>'
|
||||
+ '</div>';
|
||||
}
|
||||
function _injectTasks(id) {
|
||||
var pool = POOLS()[id]; if (!pool) return;
|
||||
var body = document.getElementById(id + '-body'); if (!body || body.querySelector('.legacy-tasks')) return;
|
||||
if (!SEC[id]) SEC[id] = { idx: 0, results: pool.map(function () { return null; }), selections: pool.map(function () { return null; }), answered: false };
|
||||
body.insertAdjacentHTML('beforeend', _makeTaskBlock(id));
|
||||
setTimeout(function () { try { renderTask(id); } catch (e) {} }, 50);
|
||||
}
|
||||
|
||||
/* ── навигация по § ────────────────────────────────────────────── */
|
||||
function goTo(id) {
|
||||
STATE.current = id; ensureBuilt(id);
|
||||
document.querySelectorAll('.sec').forEach(function (s) { s.classList.remove('active'); });
|
||||
var el = document.getElementById('sec-' + id); if (el) el.classList.add('active');
|
||||
document.querySelectorAll('.psel-card').forEach(function (c) { c.classList.toggle('active', c.dataset.id === id); });
|
||||
buildSidebar(id);
|
||||
try { W.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) {}
|
||||
if ((STATE.progress[id] || 0) < 10) bumpProgress(id, 10);
|
||||
if (W.renderMathInElement && el) setTimeout(function () { renderMath(el); }, 0);
|
||||
markLastPara(id);
|
||||
}
|
||||
|
||||
/* ── sidebar ───────────────────────────────────────────────────── */
|
||||
function buildSidebar(id) {
|
||||
var box = document.getElementById('sidebar-content'); if (!box) return;
|
||||
var SB = W.SIDEBARS || {}; var sb = SB[id] || SB[(PARAS()[0] || {}).id] || { title: '', rows: [] };
|
||||
var xpLv = xpForLevel(STATE.level), xpNext = xpForLevel(STATE.level + 1);
|
||||
var pct = (xpNext - xpLv) > 0 ? Math.round((STATE.xp - xpLv) / (xpNext - xpLv) * 100) : 100;
|
||||
var html = '<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. ' + STATE.level + '</span></div>'
|
||||
+ '<div class="xp-bar"><div class="xp-fill" style="width:' + pct + '%"></div></div>'
|
||||
+ '<div class="xp-nums"><span>' + STATE.xp + ' XP</span><span>' + xpNext + ' XP</span></div></div>';
|
||||
html += '<div class="sidecard"><h4>' + sb.title + '</h4>';
|
||||
sb.rows.forEach(function (r) { html += '<div class="sidecard-row"><b>' + r[0] + '</b>' + (r[1] ? ' — ' + r[1] : '') + '</div>'; });
|
||||
html += '</div>';
|
||||
var tips = W.TIPS || []; var tip = tips.filter(function (t) { return t.sec === id; })[0] || tips[0];
|
||||
if (tip) html += '<div class="sidecard tip"><h4><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 22 20 2 20"/></svg> Подсказка</h4><div class="sidecard-row" style="font-size:.84rem;line-height:1.55">' + tip.html + '</div></div>';
|
||||
if (STATE.achievements.size > 0) {
|
||||
html += '<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">' + STATE.achievements.size + '</span></h4>';
|
||||
var vals = []; STATE.achievements.forEach(function (v) { vals.push(v); });
|
||||
vals.slice(-4).forEach(function (t) { html += '<div class="sidecard-row done">✓ ' + t + '</div>'; });
|
||||
html += '</div>';
|
||||
}
|
||||
box.innerHTML = html;
|
||||
if (W.renderMathInElement) try { renderMath(box); } catch (e) {}
|
||||
}
|
||||
|
||||
/* ── карточки / навигация / кнопка прочтения ───────────────────── */
|
||||
var ICONS = {
|
||||
theory: '<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||
example: '<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
|
||||
rule: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
|
||||
lab: '<svg class="ic" viewBox="0 0 24 24"><path d="M10 2v7.5L4.5 19a2 2 0 0 0 1.7 3h11.6a2 2 0 0 0 1.7-3L14 9.5V2"/><line x1="9" y1="2" x2="15" y2="2"/></svg>'
|
||||
};
|
||||
function makeCard(kind, title, num, body) {
|
||||
var labels = { theory: 'Теория', example: 'Пример', rule: 'Правило', lab: 'Практика' };
|
||||
return '<div class="card"><div class="card-header"><div class="card-icon ' + kind + '">' + (ICONS[kind] || ICONS.theory) + '</div>'
|
||||
+ '<div class="card-title">' + (labels[kind] || '') + (title && title !== labels[kind] ? ' \xb7 ' + title : '') + '</div>'
|
||||
+ (num ? '<div class="card-num">' + num + '</div>' : '') + '</div><div class="card-body">' + body + '</div></div>';
|
||||
}
|
||||
function paraName(id) { var p = PARAS().filter(function (x) { return x.id === id; })[0]; return p ? p.num : id; }
|
||||
function secNav(prev, next) {
|
||||
var h = '<div class="sec-nav">';
|
||||
h += prev ? '<button class="btn" onclick="goTo(\'' + prev + '\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> ' + paraName(prev) + '</button>' : '<span></span>';
|
||||
h += next ? '<button class="btn primary" onclick="goTo(\'' + next + '\')">' + paraName(next) + ' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>' : '<span></span>';
|
||||
return h + '</div>';
|
||||
}
|
||||
function readButton(paraId) {
|
||||
var p = PARAS().filter(function (x) { return x.id === paraId; })[0];
|
||||
var tail = p && p.final ? 'финал' : (p ? p.num : '?');
|
||||
return '<div class="read-wrap"><button class="btn primary" id="' + paraId + '-read-btn">'
|
||||
+ '<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg> Я изучил — ' + tail + ' (+10 XP)</button></div>';
|
||||
}
|
||||
function wireReadBtn(paraId) {
|
||||
var btn = document.getElementById(paraId + '-read-btn'); if (!btn || btn._wired) return; btn._wired = 1;
|
||||
btn.addEventListener('click', function () {
|
||||
addXp(10, paraId + '-read'); bumpProgress(paraId, 30);
|
||||
btn.textContent = 'Изучено! +10 XP'; btn.disabled = true; btn.style.opacity = .6;
|
||||
var aId = paraId + '_done'; if (ACHL()[aId]) achievement(aId);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMath(root) {
|
||||
if (!W.renderMathInElement) return;
|
||||
try { W.renderMathInElement(root, { delimiters: [{ left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }], throwOnError: false }); } catch (e) {}
|
||||
}
|
||||
function doRender(el) { renderMath(el); }
|
||||
|
||||
/* ── ДВИЖОК ЗАДАЧ ──────────────────────────────────────────────── */
|
||||
function renderTask(sec) {
|
||||
var pool = POOLS()[sec], s = SEC[sec];
|
||||
var area = document.getElementById('taskArea' + sec), fb = document.getElementById('fb' + sec), sum = document.getElementById('sum' + sec);
|
||||
if (!area || !fb || !sum || !pool || !s) return;
|
||||
sum.classList.remove('show');
|
||||
var q = pool[s.idx], done = s.results[s.idx] !== null, isMcq = !!q.opts;
|
||||
s.answered = done;
|
||||
if (isMcq) {
|
||||
var selIdx = s.selections[s.idx];
|
||||
area.innerHTML = '<div class="task-card"><div class="task-num">Задача ' + (s.idx + 1) + ' из ' + pool.length + ' · Тест</div>'
|
||||
+ '<div class="task-text">' + q.q + '</div><div class="mcq-opts">'
|
||||
+ q.opts.map(function (opt, i) {
|
||||
var cls = 'mcq-opt'; if (done) { if (i === q.a) cls += ' mcq-cor'; else if (i === selIdx) cls += ' mcq-wrong'; }
|
||||
return '<button class="' + cls + '" id="mcqOpt' + sec + '_' + i + '" onclick="' + (done ? '' : 'selectMcq(\'' + sec + '\',' + i + ')') + '" ' + (done ? 'disabled' : '') + '><span class="mcq-let">' + String.fromCharCode(65 + i) + '.</span>' + opt + '</button>';
|
||||
}).join('') + '</div></div>';
|
||||
} else {
|
||||
area.innerHTML = '<div class="task-card"><div class="task-num">Задача ' + (s.idx + 1) + ' из ' + pool.length + '</div>'
|
||||
+ '<div class="task-text">' + q.q + '</div>'
|
||||
+ (q.hint ? '<div class="task-hint"><svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg><span>' + q.hint + '</span></div>' : '')
|
||||
+ '<div class="ans-row"><label>Ответ:</label><input class="ans-inp" type="text" id="ainp' + sec + '" placeholder="?" autocomplete="off"' + (done ? ' disabled' : '') + '>'
|
||||
+ '<span class="unit-lbl">' + (q.unit || '') + '</span>'
|
||||
+ (done ? '' : '<button class="btn primary" onclick="checkNum(\'' + sec + '\')">Проверить</button>') + '</div></div>';
|
||||
}
|
||||
if (done) {
|
||||
var ok = s.results[s.idx];
|
||||
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
|
||||
fb.innerHTML = isMcq
|
||||
? (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.opts[q.a] + '</b>. ' + (q.ex || ''))
|
||||
: (ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.a + ' ' + (q.unit || '') + '</b>. ' + (q.ex || ''));
|
||||
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
|
||||
doRender(fb);
|
||||
} else { fb.className = 'feedback'; var nb2 = document.getElementById('nextBtn' + sec); if (nb2) nb2.style.display = 'none'; }
|
||||
updateScoreBar(sec); renderNav(sec); doRender(area);
|
||||
if (!done && !isMcq) {
|
||||
var inp = document.getElementById('ainp' + sec);
|
||||
// preventScroll: иначе фокус прокручивает страницу к блоку задач (внизу §)
|
||||
setTimeout(function () { if (inp) { try { inp.focus({ preventScroll: true }); } catch (e) { inp.focus(); } } }, 80);
|
||||
if (inp) inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') checkNum(sec); });
|
||||
}
|
||||
}
|
||||
|
||||
function selectMcq(sec, i) {
|
||||
var s = SEC[sec]; if (!s || s.answered) return;
|
||||
var q = POOLS()[sec][s.idx], ok = i === q.a;
|
||||
s.results[s.idx] = ok; s.selections[s.idx] = i; s.answered = true;
|
||||
if (ok) maybeAwardTask(sec);
|
||||
q.opts.forEach(function (_, j) {
|
||||
var btn = document.getElementById('mcqOpt' + sec + '_' + j); if (!btn) return;
|
||||
btn.disabled = true; if (j === q.a) btn.classList.add('mcq-cor'); else if (j === i && !ok) btn.classList.add('mcq-wrong');
|
||||
});
|
||||
var fb = document.getElementById('fb' + sec);
|
||||
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
|
||||
fb.innerHTML = ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.opts[q.a] + '</b>. ' + (q.ex || '');
|
||||
doRender(fb);
|
||||
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
|
||||
updateScoreBar(sec); renderNav(sec); finishCheck(sec);
|
||||
}
|
||||
|
||||
function checkNum(sec) {
|
||||
var s = SEC[sec]; if (!s || s.answered) return;
|
||||
var q = POOLS()[sec][s.idx], inp = document.getElementById('ainp' + sec), fb = document.getElementById('fb' + sec);
|
||||
var val = (inp.value || '').trim().replace(',', '.'), num = parseFloat(val);
|
||||
if (!val || isNaN(num)) { fb.className = 'feedback show fb-fail'; fb.innerHTML = 'Введите числовой ответ!'; return; }
|
||||
s.answered = true;
|
||||
var tol = q.tol !== undefined ? q.tol : 0.03;
|
||||
var ok = q.a === 0 ? Math.abs(num) < 0.05 : Math.abs((num - q.a) / q.a) < tol;
|
||||
s.results[s.idx] = ok; if (ok) maybeAwardTask(sec);
|
||||
inp.disabled = true; inp.style.borderColor = ok ? 'var(--ok)' : 'var(--fail)';
|
||||
fb.className = 'feedback show ' + (ok ? 'fb-ok' : 'fb-fail');
|
||||
fb.innerHTML = ok ? 'Верно! ' + (q.ex || '') : 'Неверно. Правильный ответ: <b>' + q.a + ' ' + (q.unit || '') + '</b>. ' + (q.ex || '');
|
||||
doRender(fb);
|
||||
var nb = document.getElementById('nextBtn' + sec); if (nb) nb.style.display = 'inline-flex';
|
||||
updateScoreBar(sec); renderNav(sec); finishCheck(sec);
|
||||
}
|
||||
|
||||
function maybeAwardTask(sec) {
|
||||
var s = SEC[sec]; if (s._awarded === undefined) s._awarded = {};
|
||||
if (s._awarded[s.idx]) return; s._awarded[s.idx] = 1; addXp(5, sec + '-task');
|
||||
}
|
||||
function finishCheck(sec) {
|
||||
var s = SEC[sec];
|
||||
if (s.results.every(function (r) { return r !== null; })) setTimeout(function () { showSummary(sec); }, 1600);
|
||||
}
|
||||
|
||||
function nextTask(sec) {
|
||||
var s = SEC[sec], pool = POOLS()[sec];
|
||||
var next = -1;
|
||||
for (var k = 1; k <= pool.length; k++) { var j = (s.idx + k) % pool.length; if (s.results[j] === null) { next = j; break; } }
|
||||
if (next === -1) { showSummary(sec); return; }
|
||||
s.idx = next; s.answered = s.results[next] !== null; renderTask(sec);
|
||||
}
|
||||
function goToTask(sec, idx) { var s = SEC[sec]; s.idx = idx; s.answered = s.results[idx] !== null; renderTask(sec); }
|
||||
function resetTasks(sec) {
|
||||
var pool = POOLS()[sec];
|
||||
SEC[sec] = { idx: 0, results: pool.map(function () { return null; }), selections: pool.map(function () { return null; }), answered: false, _awarded: {} };
|
||||
var sum = document.getElementById('sum' + sec); if (sum) sum.classList.remove('show');
|
||||
renderTask(sec);
|
||||
}
|
||||
|
||||
function renderNav(sec) {
|
||||
var s = SEC[sec], pool = POOLS()[sec], nd = document.getElementById('navDots' + sec); if (!nd) return;
|
||||
nd.innerHTML = pool.map(function (_, i) {
|
||||
var cls = 'nav-dot'; if (i === s.idx) cls += ' nd-cur'; if (s.results[i] === true) cls += ' nd-ok'; else if (s.results[i] === false) cls += ' nd-fail';
|
||||
return '<button class="' + cls + '" onclick="goToTask(\'' + sec + '\',' + i + ')">' + (i + 1) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
function updateScoreBar(sec) {
|
||||
var s = SEC[sec], pool = POOLS()[sec];
|
||||
var ok = s.results.filter(function (r) { return r === true; }).length;
|
||||
var ans = s.results.filter(function (r) { return r !== null; }).length;
|
||||
setTxt('ok' + sec, ok); setTxt('cur' + sec, ans); setTxt('max' + sec, pool.length);
|
||||
var pf = document.getElementById('prog' + sec); if (pf) pf.style.width = Math.round(ans / pool.length * 100) + '%';
|
||||
}
|
||||
function showSummary(sec) {
|
||||
var s = SEC[sec], pool = POOLS()[sec], sum = document.getElementById('sum' + sec); if (!sum) return;
|
||||
var ok = s.results.filter(function (r) { return r === true; }).length;
|
||||
setTxt('sumScore' + sec, ok + ' / ' + pool.length);
|
||||
var grade = ok === pool.length ? 'Отлично! Все задачи решены.' : ok >= pool.length * 0.6 ? 'Хорошо! Можно повторить ошибки.' : 'Стоит повторить параграф.';
|
||||
setTxt('sumGrade' + sec, grade);
|
||||
sum.classList.add('show');
|
||||
if (ok === pool.length) { bumpProgress(sec, 60); var aId = sec + '_tasks'; if (ACHL()[aId]) achievement(aId); }
|
||||
}
|
||||
function setTxt(id, v) { var e = document.getElementById(id); if (e) e.textContent = v; }
|
||||
|
||||
/* ── тема ──────────────────────────────────────────────────────── */
|
||||
function initTheme() {
|
||||
var t = localStorage.getItem(K.theme) || localStorage.getItem('theme') || 'light';
|
||||
if (t === 'dark') document.documentElement.classList.add('dark');
|
||||
var lab = document.getElementById('theme-lab'); if (lab) lab.textContent = t === 'dark' ? 'Светлая' : 'Тёмная';
|
||||
var btn = document.getElementById('theme-btn'); if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
document.documentElement.classList.toggle('dark');
|
||||
var d = document.documentElement.classList.contains('dark');
|
||||
localStorage.setItem(K.theme, d ? 'dark' : 'light'); localStorage.setItem('theme', d ? 'dark' : 'light');
|
||||
if (lab) lab.textContent = d ? 'Светлая' : 'Тёмная';
|
||||
});
|
||||
}
|
||||
|
||||
/* ── init ──────────────────────────────────────────────────────── */
|
||||
function init() {
|
||||
resolveCfg();
|
||||
loadProgress(); initTheme(); buildParaSelector(); refreshUI();
|
||||
if (ACHL().start) achievement('start');
|
||||
var first = (PARAS()[0] || {}).id; if (first) goTo(first);
|
||||
refreshUI(); loadServerReadState();
|
||||
W.addEventListener('focus', loadServerReadState);
|
||||
}
|
||||
|
||||
/* экспорт */
|
||||
W.goTo = goTo; W.ensureBuilt = ensureBuilt;
|
||||
W.checkNum = checkNum; W.selectMcq = selectMcq; W.nextTask = nextTask; W.goToTask = goToTask; W.resetTasks = resetTasks;
|
||||
W.renderTask = renderTask;
|
||||
W.makeCard = makeCard; W.secNav = secNav; W.readButton = readButton; W.wireReadBtn = wireReadBtn;
|
||||
W.addXp = addXp; W.achievement = achievement; W.bumpProgress = bumpProgress; W.chem8RenderMath = renderMath;
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
|
||||
})(window);
|
||||
@@ -0,0 +1,183 @@
|
||||
/* 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);
|
||||
@@ -0,0 +1,146 @@
|
||||
/* chem8_intro_widgets.js — виджеты вводного раздела «Химия 8».
|
||||
* Монтируются движком chem8_engine.js: window.CHEM8_WIDGETS[id] / window.FLAG_MOUNTS[id].
|
||||
* Используют window.Chem8 (chem8_svg.js): molarMass, elementCounts, arOf, fmt,
|
||||
* moleTriangle, equationBalancer.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
function C() { return W.Chem8 || {}; }
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function rr(v, d) { var p = Math.pow(10, d == null ? 3 : d); return (Math.round(v * p) / p).toString().replace('.', ','); }
|
||||
|
||||
/* §1 — карта элементов */
|
||||
var EL = {
|
||||
H: [1, 'Водород'], He: [2, 'Гелий'], Li: [3, 'Литий'], Be: [4, 'Бериллий'], B: [5, 'Бор'], C: [6, 'Углерод'],
|
||||
N: [7, 'Азот'], O: [8, 'Кислород'], F: [9, 'Фтор'], Ne: [10, 'Неон'], Na: [11, 'Натрий'], Mg: [12, 'Магний'],
|
||||
Al: [13, 'Алюминий'], Si: [14, 'Кремний'], P: [15, 'Фосфор'], S: [16, 'Сера'], Cl: [17, 'Хлор'], Ar: [18, 'Аргон'],
|
||||
K: [19, 'Калий'], Ca: [20, 'Кальций'], Fe: [26, 'Железо'], Cu: [29, 'Медь'], Zn: [30, 'Цинк'], Ag: [47, 'Серебро'], Ba: [56, 'Барий']
|
||||
};
|
||||
function mount_p1() {
|
||||
var grid = $('p1-el'), info = $('p1-elinfo'); if (!grid || grid._built) return; grid._built = 1;
|
||||
Object.keys(EL).forEach(function (s) {
|
||||
var ar = C().arOf ? C().arOf(s) : '';
|
||||
var c = document.createElement('div'); c.className = 'el-cell';
|
||||
c.innerHTML = '<span class="z">' + EL[s][0] + '</span><span class="s">' + s + '</span><span class="a">' + ar + '</span>';
|
||||
c.addEventListener('click', function () {
|
||||
grid.querySelectorAll('.el-cell').forEach(function (x) { x.classList.remove('on'); }); c.classList.add('on');
|
||||
info.innerHTML = '<b>' + EL[s][1] + '</b> (' + s + ') · порядковый номер Z = ' + EL[s][0] + ' · A_r = ' + ar;
|
||||
});
|
||||
grid.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
/* §2 — калькулятор Mr */
|
||||
function mount_p2() {
|
||||
var inp = $('p2-mr-in'), out = $('p2-mr-out'), go = $('p2-mr-go'); if (!inp || inp._built) return; inp._built = 1;
|
||||
function calc() {
|
||||
var f = inp.value.trim(), cnt = C().elementCounts ? C().elementCounts(f) : null, mr = C().molarMass ? C().molarMass(f) : NaN;
|
||||
if (!cnt || isNaN(mr)) { out.className = 'out bad'; out.textContent = 'Не удалось разобрать формулу. Проверьте символы элементов.'; return; }
|
||||
out.className = 'out ok';
|
||||
out.innerHTML = '<b>M_r(' + f + ') = ' + C().fmt(mr) + '</b><br><span class="bd">' +
|
||||
Object.keys(cnt).map(function (e) { return e + ': A_r=' + (C().arOf ? C().arOf(e) : '?') + ' × ' + cnt[e]; }).join(' | ') +
|
||||
'<br>Σ = ' + Object.keys(cnt).map(function (e) { return (C().arOf ? C().arOf(e) : '?') + '·' + cnt[e]; }).join(' + ') + ' = ' + C().fmt(mr) + '</span>';
|
||||
}
|
||||
go.addEventListener('click', calc);
|
||||
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); });
|
||||
document.querySelectorAll('.p2-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); });
|
||||
calc();
|
||||
}
|
||||
|
||||
/* §3 — порция вещества */
|
||||
function mount_p3() {
|
||||
var sub = $('p3-sub'), rng = $('p3-n'), nv = $('p3-nv'), out = $('p3-out'); if (!sub || sub._built) return; sub._built = 1;
|
||||
var M = { H2O: 18, O2: 32, CO2: 44, NaCl: 58.5 };
|
||||
function upd() {
|
||||
var n = parseFloat(rng.value), s = sub.value, m = n * M[s], N = n * 6.02;
|
||||
nv.textContent = n.toFixed(1).replace('.', ',');
|
||||
out.innerHTML = '<span class="bd">n = ' + n.toFixed(1).replace('.', ',') + ' моль<br>m = n·M = ' + n.toFixed(1).replace('.', ',') + ' · ' + String(M[s]).replace('.', ',') + ' = <b>' + rr(m, 1) + ' г</b><br>N = n·N_A = <b>' + rr(N, 2) + '·10²³ частиц</b></span>';
|
||||
}
|
||||
sub.addEventListener('change', upd); rng.addEventListener('input', upd); upd();
|
||||
}
|
||||
|
||||
/* §4 — счётчик частиц */
|
||||
function mount_p4() {
|
||||
var rng = $('p4-n'), nv = $('p4-nv'), out = $('p4-out'); if (!rng || rng._built) return; rng._built = 1;
|
||||
function upd() { var n = parseFloat(rng.value), N = n * 6.02; nv.textContent = n.toFixed(2).replace('.', ',');
|
||||
out.innerHTML = '<span class="bd">N = n · N_A = ' + n.toFixed(2).replace('.', ',') + ' · 6,02·10²³ = <b>' + rr(N, 2) + '·10²³ частиц</b></span>'; }
|
||||
rng.addEventListener('input', upd); upd();
|
||||
}
|
||||
|
||||
/* §5 — M + объём газа */
|
||||
function mount_p5() {
|
||||
var inp = $('p5-in'), out = $('p5-out'), go = $('p5-go'); if (!inp || inp._built) return; inp._built = 1;
|
||||
function calc() {
|
||||
var f = inp.value.trim(), mr = C().molarMass ? C().molarMass(f) : NaN;
|
||||
if (isNaN(mr)) { out.className = 'out bad'; out.textContent = 'Не удалось разобрать формулу.'; return; }
|
||||
out.className = 'out ok';
|
||||
out.innerHTML = '<span class="bd">M(' + f + ') = <b>' + C().fmt(mr) + ' г/моль</b><br>1 моль газа при н.у. → <b>22,4 л</b><br>Плотность газа ≈ M/22,4 = ' + rr(mr / 22.4) + ' г/л</span>';
|
||||
}
|
||||
go.addEventListener('click', calc); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc();
|
||||
}
|
||||
|
||||
/* §6 / ПР1 — треугольник n–m–M (флагман) */
|
||||
function mount_triangle(mountId, subId) {
|
||||
var mount = $(mountId), sub = $(subId); if (!mount || mount._built || !C().moleTriangle) return; mount._built = 1;
|
||||
var api = C().moleTriangle(mount, {});
|
||||
if (sub) sub.addEventListener('change', function () {
|
||||
var f = sub.value; if (!f) return; var m = C().molarMass(f);
|
||||
if (!isNaN(m) && api && api.set) api.set('M', m);
|
||||
});
|
||||
}
|
||||
function mount_p6() { mount_triangle('p6-mount', 'p6-sub'); }
|
||||
function mount_pr1() { mount_triangle('pr1-mount', 'pr1-sub'); }
|
||||
|
||||
/* §7 — универсальный калькулятор газа (флагман) */
|
||||
function mount_p7() {
|
||||
var sub = $('p7-sub'), key = $('p7-key'), val = $('p7-val'), go = $('p7-go'), out = $('p7-out'); if (!sub || sub._built) return; sub._built = 1;
|
||||
var Vm = 22.4, NA = 6.02;
|
||||
function calc() {
|
||||
var f = sub.value, M = C().molarMass(f), k = key.value, x = parseFloat((val.value || '').replace(',', '.'));
|
||||
if (isNaN(x)) { out.className = 'out bad'; out.textContent = 'Введите число.'; return; }
|
||||
var n; if (k === 'n') n = x; else if (k === 'm') n = x / M; else if (k === 'V') n = x / Vm; else n = x / NA;
|
||||
var m = n * M, V = n * Vm, N = n * NA;
|
||||
out.className = 'out ok';
|
||||
out.innerHTML = '<span class="bd">M(' + f + ')=' + M + ' г/моль<br>n = <b>' + rr(n) + ' моль</b><br>m = <b>' + rr(m) + ' г</b><br>V(н.у.) = <b>' + rr(V) + ' л</b><br>N = <b>' + rr(N) + '·10²³ частиц</b></span>';
|
||||
}
|
||||
go.addEventListener('click', calc); val.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); }); calc();
|
||||
}
|
||||
|
||||
/* §8 — балансировщик (флагман) */
|
||||
function mount_p8() {
|
||||
var pick = $('p8-pick'), mount = $('p8-mount'); if (!pick || pick._built || !C().equationBalancer) return; pick._built = 1;
|
||||
function build() { var parts = pick.value.split('|'); C().equationBalancer(mount, { skeleton: parts[0], solution: parts[1].split(',').map(Number) }); }
|
||||
pick.addEventListener('change', build); build();
|
||||
}
|
||||
|
||||
/* §9 — пошаговый решатель (флагман) */
|
||||
var ST = [
|
||||
{ eq: '2H₂ + O₂ → 2H₂O', given: 'Дано: m(H₂) = 4 г. Найти m(H₂O).',
|
||||
steps: ['M(H₂)=2 г/моль, M(H₂O)=18 г/моль.', 'n(H₂) = m/M = 4/2 = 2 моль.', 'По уравнению n(H₂):n(H₂O) = 2:2 = 1:1 → n(H₂O)=2 моль.', 'm(H₂O) = n·M = 2·18 = 36 г. Ответ: 36 г.'] },
|
||||
{ eq: 'CaCO₃ → CaO + CO₂↑', given: 'Дано: m(CaCO₃) = 100 г. Найти V(CO₂) при н.у.',
|
||||
steps: ['M(CaCO₃)=100 г/моль.', 'n(CaCO₃) = 100/100 = 1 моль.', 'n(CaCO₃):n(CO₂) = 1:1 → n(CO₂)=1 моль.', 'V(CO₂) = n·Vm = 1·22,4 = 22,4 л. Ответ: 22,4 л.'] },
|
||||
{ eq: 'Zn + 2HCl → ZnCl₂ + H₂↑', given: 'Дано: n(Zn) = 0,5 моль. Найти V(H₂) при н.у.',
|
||||
steps: ['n(Zn):n(H₂) = 1:1 → n(H₂)=0,5 моль.', 'V(H₂) = n·Vm = 0,5·22,4 = 11,2 л. Ответ: 11,2 л.'] }
|
||||
];
|
||||
function mount_p9() {
|
||||
var pick = $('p9-pick'), out = $('p9-out'), bStep = $('p9-step'), bAll = $('p9-all'); if (!pick || pick._built) return; pick._built = 1;
|
||||
ST.forEach(function (p, i) { var o = document.createElement('option'); o.value = i; o.textContent = p.eq; pick.appendChild(o); });
|
||||
var cur = 0, shown = 0;
|
||||
function render() {
|
||||
var p = ST[cur];
|
||||
var html = '<b>' + p.eq + '</b><br><span style="color:var(--muted)">' + p.given + '</span><div style="margin-top:8px">';
|
||||
for (var i = 0; i < shown; i++) html += '<div class="def-box" style="margin:6px 0">' + p.steps[i] + '</div>';
|
||||
if (shown === 0) html += '<span style="color:var(--muted)">Нажмите «Следующий шаг», чтобы решать пошагово.</span>';
|
||||
html += '</div>'; out.className = shown >= p.steps.length ? 'out ok' : 'out'; out.innerHTML = html;
|
||||
if (W.chem8RenderMath) try { W.chem8RenderMath(out); } catch (e) {}
|
||||
}
|
||||
pick.addEventListener('change', function () { cur = +pick.value; shown = 0; render(); });
|
||||
bStep.addEventListener('click', function () { if (shown < ST[cur].steps.length) { shown++; render(); } });
|
||||
bAll.addEventListener('click', function () { shown = ST[cur].steps.length; render(); });
|
||||
render();
|
||||
}
|
||||
function mount_final1(){ var el=$('c-concept'); if(el&&!el._b&&C().conceptMap){ el._b=1; C().conceptMap(el,{"nodes":[{"id":"n","t":"n, моль","x":170,"y":55,"c":"#d97706"},{"id":"m","t":"m, г","x":20,"y":22},{"id":"M","t":"M, г/моль","x":20,"y":95},{"id":"V","t":"V, л","x":330,"y":22},{"id":"N","t":"N частиц","x":330,"y":95}],"edges":[{"f":"m","t":"n","label":"n = m / M"},{"f":"M","t":"n","label":"M = m / n"},{"f":"n","t":"V","label":"V = n · 22,4 (газ, н.у.)"},{"f":"n","t":"N","label":"N = n · 6,02·10²³"}]}); } }
|
||||
|
||||
W.CHEM8_WIDGETS = { p1: mount_p1, p2: mount_p2, p3: mount_p3, p4: mount_p4, p5: mount_p5, pr1: mount_pr1 };
|
||||
W.FLAG_MOUNTS = { final1: mount_final1, p6: mount_p6, p7: mount_p7, p8: mount_p8, p9: mount_p9 };
|
||||
})(window);
|
||||
@@ -0,0 +1,132 @@
|
||||
/* chem8_mol.js — 3D-модели молекул и кристаллических решёток (U4).
|
||||
* Поверх biochem-core (window.BIO): vsepr + render3D. Вращение мышью/пальцем
|
||||
* (window-listeners, без setPointerCapture). Экспорт: window.Chem8Mol.
|
||||
*/
|
||||
(function (W) {
|
||||
'use strict';
|
||||
var D = W.document;
|
||||
function BIO() { return W.BIO; }
|
||||
function C() { return W.Chem8 || {}; }
|
||||
|
||||
/* предопределённые молекулы: atoms + bonds */
|
||||
var MOL = {
|
||||
H2: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Водород H₂' },
|
||||
Cl2: { atoms: [{ id: 1, s: 'Cl' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлор Cl₂' },
|
||||
O2: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }], name: 'Кислород O₂' },
|
||||
N2: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'N' }], bonds: [{ f: 1, t: 2, o: 3 }], name: 'Азот N₂' },
|
||||
HCl: { atoms: [{ id: 1, s: 'H' }, { id: 2, s: 'Cl' }], bonds: [{ f: 1, t: 2, o: 1 }], name: 'Хлороводород HCl' },
|
||||
H2O: { atoms: [{ id: 1, s: 'O' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }], name: 'Вода H₂O' },
|
||||
CO2: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'O' }, { id: 3, s: 'O' }], bonds: [{ f: 1, t: 2, o: 2 }, { f: 1, t: 3, o: 2 }], name: 'Углекислый газ CO₂' },
|
||||
NH3: { atoms: [{ id: 1, s: 'N' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }], name: 'Аммиак NH₃' },
|
||||
CH4: { atoms: [{ id: 1, s: 'C' }, { id: 2, s: 'H' }, { id: 3, s: 'H' }, { id: 4, s: 'H' }, { id: 5, s: 'H' }], bonds: [{ f: 1, t: 2, o: 1 }, { f: 1, t: 3, o: 1 }, { f: 1, t: 4, o: 1 }, { f: 1, t: 5, o: 1 }], name: 'Метан CH₄' }
|
||||
};
|
||||
|
||||
function mkCanvas(host, h) {
|
||||
var cv = D.createElement('canvas'); cv.className = 'mol-cv';
|
||||
cv.style.width = '100%'; cv.style.height = (h || 200) + 'px'; cv.style.touchAction = 'none';
|
||||
cv.style.borderRadius = '12px'; cv.style.display = 'block';
|
||||
host.appendChild(cv); return cv;
|
||||
}
|
||||
function fit(cv) {
|
||||
var dpr = W.devicePixelRatio || 1, w = cv.offsetWidth || 280, h = cv.offsetHeight || 200;
|
||||
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
|
||||
var ctx = cv.getContext && cv.getContext('2d'); if (!ctx) return null; // jsdom без canvas
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return { ctx: ctx, W: w, H: h };
|
||||
}
|
||||
|
||||
/* общий движок вращения: state выше redraw, window-listeners */
|
||||
function attachRotate(cv, state, redraw) {
|
||||
var dragging = false, lx = 0, ly = 0;
|
||||
cv.addEventListener('pointerdown', function (e) { dragging = true; lx = e.clientX; ly = e.clientY; state.spin = false; });
|
||||
W.addEventListener('pointermove', function (e) {
|
||||
if (!dragging) return;
|
||||
state.rotY += (e.clientX - lx) * 0.01; state.rotX += (e.clientY - ly) * 0.01;
|
||||
lx = e.clientX; ly = e.clientY; redraw();
|
||||
});
|
||||
W.addEventListener('pointerup', function () { dragging = false; });
|
||||
}
|
||||
|
||||
/* ── 3D-модель молекулы ── */
|
||||
function molModel(mount, key) {
|
||||
var host = typeof mount === 'string' ? D.querySelector(mount) : mount;
|
||||
if (!host || !BIO()) return null;
|
||||
var keys = Object.keys(MOL);
|
||||
host.innerHTML = '<div class="fld"><label>Молекула</label><select class="mol-sel">' +
|
||||
keys.map(function (k) { return '<option value="' + k + '"' + (k === key ? ' selected' : '') + '>' + MOL[k].name + '</option>'; }).join('') + '</select>'
|
||||
+ '<button class="btn mol-spin">⟳ Вращение</button></div>';
|
||||
var stage = D.createElement('div'); host.appendChild(stage);
|
||||
var cv = mkCanvas(stage, 200);
|
||||
var info = D.createElement('div'); info.className = 'out mol-info'; host.appendChild(info);
|
||||
var sel = host.querySelector('.mol-sel'), spinBtn = host.querySelector('.mol-spin');
|
||||
var state = { rotX: -0.35, rotY: 0.6, scale: 2.6, spin: true };
|
||||
var cur;
|
||||
function load(k) {
|
||||
cur = MOL[k]; var g = BIO().vsepr(cur.atoms, cur.bonds); cur.g = g;
|
||||
var pol = BIO().polarity(cur.atoms, cur.bonds);
|
||||
var mr = C().molarMass ? C().molarMass(k) : BIO().molarMass(cur.atoms);
|
||||
var bondTxt = cur.atoms.length === 2 && C().bondClass
|
||||
? C().bondClass(cur.atoms[0].s, cur.atoms[1].s).type
|
||||
: (pol.label === 'Ионная' ? 'ионная' : 'ковалентная');
|
||||
info.className = 'out mol-info ok';
|
||||
info.innerHTML = '<span class="bd"><b>' + cur.name + '</b> · M = ' + (C().fmt ? C().fmt(mr) : mr) + ' г/моль<br>'
|
||||
+ 'Связь: ' + bondTxt + ' · молекула: <b>' + pol.label.toLowerCase() + '</b>'
|
||||
+ (g.shape ? ' · форма: ' + g.shape : '') + '</span>';
|
||||
}
|
||||
function redraw() {
|
||||
var d = fit(cv); if (!d) return;
|
||||
BIO().render3D(d.ctx, cur.g.atoms3d, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' });
|
||||
}
|
||||
sel.addEventListener('change', function () { load(sel.value); redraw(); });
|
||||
spinBtn.addEventListener('click', function () { state.spin = !state.spin; spinBtn.classList.toggle('primary', state.spin); });
|
||||
attachRotate(cv, state, redraw);
|
||||
load(key && MOL[key] ? key : keys[0]);
|
||||
redraw();
|
||||
if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.012; redraw(); } W.requestAnimationFrame(loop); })(); // не стартуем цикл без canvas-контекста (jsdom)
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ── кристаллические решётки (§41) ── */
|
||||
var LAT = {
|
||||
ionic: { name: 'Ионная (NaCl)', build: function () { return cube(['Na', 'Cl']); }, note: 'Узлы — ионы Na⁺ и Cl⁻. Прочная решётка → тугоплавкие, твёрдые вещества.' },
|
||||
atomic: { name: 'Атомная (алмаз)', build: function () { return cube(['C', 'C']); }, note: 'Узлы — атомы, связанные ковалентно. Очень твёрдые, тугоплавкие.' },
|
||||
molecular: { name: 'Молекулярная (лёд)', build: function () { return cube(['O', 'O']); }, note: 'Узлы — молекулы со слабым притяжением. Летучие, легкоплавкие.' },
|
||||
metallic: { name: 'Металлическая (Fe)', build: function () { return cube(['Fe', 'Fe'], true); }, note: 'Ион-остовы металла в «электронном газе». Ковкие, проводят ток.' }
|
||||
};
|
||||
function cube(symPair, electrons) {
|
||||
var L = 16, atoms = [], id = 1;
|
||||
for (var xi = -1; xi <= 1; xi += 2) for (var yi = -1; yi <= 1; yi += 2) for (var zi = -1; zi <= 1; zi += 2) {
|
||||
var parity = ((xi + yi + zi) / 2 + 3) % 2;
|
||||
atoms.push({ id: id++, s: symPair[parity], x: xi * L, y: yi * L, z: zi * L });
|
||||
}
|
||||
var bonds = [];
|
||||
for (var i = 0; i < atoms.length; i++) for (var j = i + 1; j < atoms.length; j++) {
|
||||
var a = atoms[i], b = atoms[j], dd = Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + Math.abs(a.z - b.z);
|
||||
if (dd === 2 * L) bonds.push({ f: a.id, t: b.id, o: 1 });
|
||||
}
|
||||
if (electrons) for (var e = 0; e < 6; e++) atoms.push({ id: id++, s: 'H', x: (e % 3 - 1) * L, y: ((e / 3 | 0) * 2 - 1) * L * 0.5, z: 0 }); // «электроны» как мелкие точки (H — мелкий радиус)
|
||||
return { atoms: atoms, bonds: bonds };
|
||||
}
|
||||
function latticeViewer(mount, type) {
|
||||
var host = typeof mount === 'string' ? D.querySelector(mount) : mount;
|
||||
if (!host || !BIO()) return null;
|
||||
var keys = Object.keys(LAT);
|
||||
host.innerHTML = '<div class="fld"><label>Тип решётки</label><select class="lat-sel">' +
|
||||
keys.map(function (k) { return '<option value="' + k + '"' + (k === type ? ' selected' : '') + '>' + LAT[k].name + '</option>'; }).join('') + '</select></div>';
|
||||
var stage = D.createElement('div'); host.appendChild(stage);
|
||||
var cv = mkCanvas(stage, 200);
|
||||
var info = D.createElement('div'); info.className = 'out'; host.appendChild(info);
|
||||
var sel = host.querySelector('.lat-sel');
|
||||
var state = { rotX: -0.4, rotY: 0.5, scale: 2.4, spin: true };
|
||||
var cur;
|
||||
function load(k) { var l = LAT[k]; cur = l.build(); info.className = 'out ok'; info.innerHTML = '<span class="bd"><b>' + l.name + '</b><br>' + l.note + '</span>'; }
|
||||
function redraw() { var d = fit(cv); if (!d) return; BIO().render3D(d.ctx, cur.atoms, cur.bonds, { W: d.W, H: d.H, rotX: state.rotX, rotY: state.rotY, scale: state.scale }, { bg: '#0b1220' }); }
|
||||
sel.addEventListener('change', function () { load(sel.value); redraw(); });
|
||||
attachRotate(cv, state, redraw);
|
||||
load(type && LAT[type] ? type : keys[0]); redraw();
|
||||
if (fit(cv)) (function loop() { if (state.spin) { state.rotY += 0.01; redraw(); } W.requestAnimationFrame(loop); })();
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
W.Chem8Mol = { molModel: molModel, latticeViewer: latticeViewer, MOL: MOL };
|
||||
})(window);
|
||||
@@ -0,0 +1,980 @@
|
||||
/* chem8_svg.js — химические наглядные примитивы для учебника «Химия 8».
|
||||
*
|
||||
* Неймспейс: window.Chem8.*
|
||||
* Молекулярные модели (структурные / шаростержневые / 3D) — НЕ здесь, а через
|
||||
* biochem-core.js (window.BioChem). Здесь только то, чего там нет: рендер формул и
|
||||
* уравнений, ионы, степени окисления, интерактивные виджеты (растворимость, ряд
|
||||
* активности, индикаторы, классификаторы, калькуляторы расчётов и т. п.).
|
||||
*
|
||||
* Phase 0: реализованы чистые текстовые примитивы (ionLabel, chemEq, formula).
|
||||
* Остальные хелперы — каркасы-заглушки, наполняются по фазам (см. PLAN_CHEMISTRY_8.md, разд. B).
|
||||
*
|
||||
* Правила (CLAUDE.md / план):
|
||||
* - без эмоджи, только inline SVG .ic;
|
||||
* - в KaTeX-шаблонах двойной backslash (\\to, \\downarrow, \\rightleftharpoons);
|
||||
* - drag/слайдеры: window-listeners + state ВЫШЕ redraw(), без setPointerCapture.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var SUB = { '0':'₀','1':'₁','2':'₂','3':'₃','4':'₄',
|
||||
'5':'₅','6':'₆','7':'₇','8':'₈','9':'₉' };
|
||||
var SUP = { '0':'⁰','1':'¹','2':'²','3':'³','4':'⁴',
|
||||
'5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹',
|
||||
'+':'⁺','-':'⁻' };
|
||||
|
||||
function toSub(digits) {
|
||||
return String(digits).replace(/[0-9]/g, function (d) { return SUB[d]; });
|
||||
}
|
||||
function toSup(s) {
|
||||
return String(s).replace(/[0-9+\-]/g, function (c) { return SUP[c] || c; });
|
||||
}
|
||||
|
||||
/* formula('CaCO3') -> 'CaCO₃' : числовые индексы атомов в подстрочные.
|
||||
Не трогает множители-коэффициенты в начале (их рендерит chemEq). */
|
||||
function formula(src) {
|
||||
if (src == null) return '';
|
||||
return String(src).replace(/([A-Za-z\)\]])(\d+)/g, function (_, a, n) {
|
||||
return a + toSub(n);
|
||||
});
|
||||
}
|
||||
|
||||
/* ionLabel('SO4', -2) -> 'SO₄²⁻' ; ionLabel('Ca', 2) -> 'Ca²⁺' ; ionLabel('Na', 1) -> 'Na⁺' */
|
||||
function ionLabel(form, charge) {
|
||||
var body = formula(form);
|
||||
var c = Number(charge) || 0;
|
||||
if (c === 0) return body;
|
||||
var mag = Math.abs(c);
|
||||
var sign = c > 0 ? '+' : '-';
|
||||
var num = mag === 1 ? '' : String(mag);
|
||||
return body + toSup(num + sign);
|
||||
}
|
||||
|
||||
/* chemEq('2Na + 2H2O -> 2NaOH + H2^', {arrow:'->'}) -> HTML-строка с индексами,
|
||||
стрелками (= → ⇌), значками газа (↑) и осадка (↓), условием над стрелкой.
|
||||
Токены: '->'/'=' необратимая, '<->'/'<=>' обратимая, '^' газ, 'v' осадок.
|
||||
opts.cond — подпись над стрелкой (например 't', 'кат.', 'эл. ток'). */
|
||||
function chemEq(src, opts) {
|
||||
opts = opts || {};
|
||||
var s = String(src == null ? '' : src).trim();
|
||||
var arrowHtml = ' <span class="ceq-arrow">' + arrowGlyph(s, opts) + condHtml(opts) + '</span> ';
|
||||
// выделяем стрелку
|
||||
var parts = s.split(/<->|<=>|->|⇌|=(?![^(]*\))|→/);
|
||||
var left = parts[0] || '';
|
||||
var right = parts.length > 1 ? parts.slice(1).join(' ') : '';
|
||||
var html = renderSide(left);
|
||||
if (right) html += arrowHtml + renderSide(right);
|
||||
return '<span class="ceq">' + html + '</span>';
|
||||
}
|
||||
|
||||
function arrowGlyph(s, opts) {
|
||||
if (opts.arrow === '<->' || opts.arrow === '<=>' || /<->|<=>|⇌/.test(s)) return '⇌';
|
||||
return '→'; // →
|
||||
}
|
||||
function condHtml(opts) {
|
||||
if (!opts.cond) return '';
|
||||
return '<sup class="ceq-cond">' + escapeHtml(opts.cond) + '</sup>';
|
||||
}
|
||||
|
||||
/* одна сторона уравнения: разбор на вещества по '+', значки ↑/↓ */
|
||||
function renderSide(side) {
|
||||
return side.split('+').map(function (term) {
|
||||
var t = term.trim();
|
||||
if (!t) return '';
|
||||
var gas = false, prec = false;
|
||||
t = t.replace(/\^|↑/g, function () { gas = true; return ''; })
|
||||
.replace(/(^|[A-Za-z0-9\)])v(\b|$)|↓/g, function (m) {
|
||||
prec = true; return m.replace(/v|↓/, '');
|
||||
});
|
||||
// коэффициент в начале
|
||||
var coef = '';
|
||||
t = t.replace(/^(\d+)/, function (_, n) { coef = n; return ''; });
|
||||
var out = (coef ? coef : '') + formula(t.trim());
|
||||
if (gas) out += '↑';
|
||||
if (prec) out += '↓';
|
||||
return out;
|
||||
}).filter(Boolean).join(' + ');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&':'&','<':'<','>':'>','"':'"',"'":''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Относительные атомные массы Ar (школьно-округлённые, как в учебнике РБ).
|
||||
Намеренно НЕ берём точные массы biochem-core: для 8 класса Mr(H₂O)=18,
|
||||
Mr(CaCO₃)=100 и т. п. — иначе расходимся с ответами учебника. ── */
|
||||
var AR = {
|
||||
H:1, He:4, Li:7, Be:9, B:11, C:12, N:14, O:16, F:19, Ne:20,
|
||||
Na:23, Mg:24, Al:27, Si:28, P:31, S:32, Cl:35.5, Ar:40, K:39, Ca:40,
|
||||
Sc:45, Ti:48, V:51, Cr:52, Mn:55, Fe:56, Co:59, Ni:59, Cu:64, Zn:65,
|
||||
Ga:70, Ge:73, As:75, Se:79, Br:80, Kr:84, Rb:85, Sr:88, Ag:108, Cd:112,
|
||||
Sn:119, Sb:122, I:127, Xe:131, Ba:137, Pt:195, Au:197, Hg:201, Pb:207, Bi:209
|
||||
};
|
||||
function arOf(sym) {
|
||||
if (Object.prototype.hasOwnProperty.call(AR, sym)) return AR[sym];
|
||||
// запасной путь — точная масса из biochem-core, если элемента нет в школьной таблице
|
||||
if (global.BIO && global.BIO.ELEMENTS && global.BIO.ELEMENTS[sym]) {
|
||||
return Math.round(global.BIO.ELEMENTS[sym].mass);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* elementCounts('Ca(OH)2') -> {Ca:1, O:2, H:2} (скобки и индексы) */
|
||||
function elementCounts(str) {
|
||||
var out = {}, stack = [out];
|
||||
var re = /([A-Z][a-z]?)(\d*)|(\()|(\))(\d*)/g, m;
|
||||
while ((m = re.exec(str)) !== null) {
|
||||
if (m[1]) {
|
||||
var n = m[2] ? parseInt(m[2], 10) : 1;
|
||||
var top = stack[stack.length - 1];
|
||||
top[m[1]] = (top[m[1]] || 0) + n;
|
||||
} else if (m[3]) {
|
||||
stack.push({});
|
||||
} else if (m[4] !== undefined) {
|
||||
var grp = stack.pop(), mult = m[5] ? parseInt(m[5], 10) : 1, t2 = stack[stack.length - 1];
|
||||
for (var k in grp) t2[k] = (t2[k] || 0) + grp[k] * mult;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* molarMass('CaCO3') -> 100 (г/моль), на школьных Ar. NaN при неизвестном элементе. */
|
||||
function molarMass(str) {
|
||||
var c = elementCounts(String(str || '').replace(/\s+/g, ''));
|
||||
var keys = Object.keys(c);
|
||||
if (!keys.length) return NaN;
|
||||
var m = 0;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var a = arOf(keys[i]);
|
||||
if (!a) return NaN;
|
||||
m += a * c[keys[i]];
|
||||
}
|
||||
return Math.round(m * 1000) / 1000;
|
||||
}
|
||||
|
||||
/* Округление до значащих для вывода (избегаем 18.000000002). */
|
||||
function fmt(x, d) {
|
||||
if (!isFinite(x)) return '—';
|
||||
var p = Math.pow(10, d == null ? 3 : d);
|
||||
return String(Math.round(x * p) / p);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
moleTriangle(mount, opts) — интерактивный калькулятор-треугольник n–m–M.
|
||||
Пользователь вводит любые два из {n, m, M} — третье считается (n=m/M,
|
||||
m=n·M, M=m/n). opts.substance — предзаполнить M по формуле (через molarMass).
|
||||
Возвращает {el, get, set}. Без setPointerCapture, чистый DOM.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
function moleTriangle(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var state = { n: '', m: '', M: opts.substance ? molarMass(opts.substance) : '' };
|
||||
var lastEdited = []; // последние два редактированных поля → третье вычисляем
|
||||
|
||||
host.innerHTML =
|
||||
'<div class="mtri">' +
|
||||
'<svg class="mtri-svg" viewBox="0 0 200 150" aria-hidden="true">' +
|
||||
'<polygon points="100,14 18,140 182,140" fill="none" stroke="currentColor" stroke-width="2" opacity=".5"/>' +
|
||||
'<line x1="59" y1="77" x2="141" y2="77" stroke="currentColor" stroke-width="1.5" opacity=".4"/>' +
|
||||
'<text x="100" y="52" text-anchor="middle" font-size="26" font-weight="800" fill="currentColor">m</text>' +
|
||||
'<text x="62" y="124" text-anchor="middle" font-size="22" font-weight="800" fill="currentColor">n</text>' +
|
||||
'<text x="140" y="124" text-anchor="middle" font-size="22" font-weight="800" fill="currentColor">M</text>' +
|
||||
'</svg>' +
|
||||
'<div class="mtri-fields">' +
|
||||
fieldHtml('n', 'n, моль', 'химическое количество') +
|
||||
fieldHtml('m', 'm, г', 'масса вещества') +
|
||||
fieldHtml('M', 'M, г/моль', 'молярная масса') +
|
||||
'</div>' +
|
||||
'<div class="mtri-out" data-out>Введите любые два значения — третье вычислится.</div>' +
|
||||
'</div>';
|
||||
|
||||
function fieldHtml(key, label, hint) {
|
||||
return '<label class="mtri-f"><span class="mtri-lab">' + label + '</span>' +
|
||||
'<input type="text" inputmode="decimal" data-k="' + key + '" placeholder="?" ' +
|
||||
'title="' + hint + '"></label>';
|
||||
}
|
||||
|
||||
var inputs = host.querySelectorAll('input[data-k]');
|
||||
var out = host.querySelector('[data-out]');
|
||||
|
||||
function num(v) { var x = parseFloat(String(v).replace(',', '.')); return isFinite(x) ? x : null; }
|
||||
|
||||
function recompute(changedKey) {
|
||||
if (lastEdited[0] !== changedKey) { lastEdited.unshift(changedKey); lastEdited = lastEdited.slice(0, 2); }
|
||||
var known = ['n', 'm', 'M'].filter(function (k) { return num(state[k]) !== null; });
|
||||
// целевое поле — то, что НЕ редактировали последним и пусто/производно
|
||||
var target = ['n', 'm', 'M'].filter(function (k) { return lastEdited.indexOf(k) === -1; })[0];
|
||||
if (!target) return;
|
||||
var n = num(state.n), m = num(state.m), M = num(state.M);
|
||||
var res = null, formula = '';
|
||||
if (target === 'n' && m !== null && M) { res = m / M; formula = 'n = m / M = ' + fmt(m) + ' / ' + fmt(M); }
|
||||
else if (target === 'm' && n !== null && M !== null) { res = n * M; formula = 'm = n · M = ' + fmt(n) + ' · ' + fmt(M); }
|
||||
else if (target === 'M' && m !== null && n) { res = m / n; formula = 'M = m / n = ' + fmt(m) + ' / ' + fmt(n); }
|
||||
if (res === null) {
|
||||
out.className = 'mtri-out';
|
||||
out.textContent = (known.length >= 2)
|
||||
? 'Проверьте: на ноль делить нельзя.'
|
||||
: 'Введите любые два значения — третье вычислится.';
|
||||
return;
|
||||
}
|
||||
var unit = target === 'n' ? ' моль' : target === 'm' ? ' г' : ' г/моль';
|
||||
setField(target, fmt(res));
|
||||
out.className = 'mtri-out ok';
|
||||
out.innerHTML = '<b>' + target + ' = ' + fmt(res) + unit + '</b><span class="mtri-form">' + formula + '</span>';
|
||||
}
|
||||
|
||||
function setField(key, val) {
|
||||
state[key] = val;
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
if (inputs[i].getAttribute('data-k') === key && global.document.activeElement !== inputs[i]) {
|
||||
inputs[i].value = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
(function (inp) {
|
||||
inp.addEventListener('input', function () {
|
||||
var k = inp.getAttribute('data-k');
|
||||
state[k] = inp.value;
|
||||
// если поле очистили — сбросить производное
|
||||
recompute(k);
|
||||
});
|
||||
})(inputs[i]);
|
||||
}
|
||||
|
||||
if (state.M) setField('M', fmt(state.M));
|
||||
|
||||
return {
|
||||
el: host,
|
||||
get: function () { return { n: num(state.n), m: num(state.m), M: num(state.M) }; },
|
||||
set: function (k, v) { setField(k, String(v)); recompute(k === 'n' ? 'm' : 'n'); }
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
equationBalancer(mount, {skeleton}) — проверка расстановки коэффициентов.
|
||||
skeleton: 'H2 + O2 -> H2O'. Рендерит поля коэффициентов перед каждым
|
||||
веществом, кнопку «Проверить»; считает баланс атомов по сторонам и
|
||||
подсвечивает несбалансированные элементы. opts.solution — массив верных
|
||||
коэффициентов (для кнопки «Показать решение»).
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
function equationBalancer(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var skel = String(opts.skeleton || '');
|
||||
var sides = skel.split(/->|=|→/);
|
||||
var left = parseSide(sides[0] || ''), right = parseSide(sides[1] || '');
|
||||
var all = left.concat(right);
|
||||
|
||||
host.innerHTML =
|
||||
'<div class="ceqb">' +
|
||||
'<div class="ceqb-row" data-eq>' +
|
||||
renderSpecies(left) + '<span class="ceqb-arrow">→</span>' + renderSpecies(right) +
|
||||
'</div>' +
|
||||
'<div class="ceqb-actions">' +
|
||||
'<button type="button" class="ceqb-btn primary" data-check>Проверить</button>' +
|
||||
(opts.solution ? '<button type="button" class="ceqb-btn" data-solve>Показать решение</button>' : '') +
|
||||
'<button type="button" class="ceqb-btn" data-reset>Сброс</button>' +
|
||||
'</div>' +
|
||||
'<div class="ceqb-out" data-out></div>' +
|
||||
'</div>';
|
||||
|
||||
function renderSpecies(list) {
|
||||
return list.map(function (sp, i) {
|
||||
var gi = all.indexOf(sp);
|
||||
return (i ? '<span class="ceqb-plus">+</span>' : '') +
|
||||
'<span class="ceqb-sp"><input type="number" min="1" step="1" class="ceqb-coef" ' +
|
||||
'data-i="' + gi + '" value="1"><span class="ceqb-f">' + formula(sp.raw) + '</span></span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
var out = host.querySelector('[data-out]');
|
||||
var coefs = host.querySelectorAll('.ceqb-coef');
|
||||
|
||||
function getCoef(i) { var v = parseInt((coefs[i] && coefs[i].value) || '1', 10); return v > 0 ? v : 1; }
|
||||
|
||||
function tally(list, fromIdx) {
|
||||
var acc = {};
|
||||
list.forEach(function (sp, j) {
|
||||
var c = getCoef(all.indexOf(sp));
|
||||
for (var e in sp.counts) acc[e] = (acc[e] || 0) + sp.counts[e] * c;
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
function check() {
|
||||
var L = tally(left), R = tally(right);
|
||||
var elems = {}; Object.keys(L).forEach(function (e) { elems[e] = 1; }); Object.keys(R).forEach(function (e) { elems[e] = 1; });
|
||||
var rows = '', ok = true;
|
||||
Object.keys(elems).sort().forEach(function (e) {
|
||||
var l = L[e] || 0, r = R[e] || 0, eq = l === r;
|
||||
if (!eq) ok = false;
|
||||
rows += '<tr class="' + (eq ? 'eq' : 'ne') + '"><td>' + e + '</td><td>' + l + '</td><td>' + r + '</td>' +
|
||||
'<td>' + (eq ? '✓' : '≠') + '</td></tr>';
|
||||
});
|
||||
out.className = 'ceqb-out ' + (ok ? 'ok' : 'bad');
|
||||
out.innerHTML = (ok ? '<div class="ceqb-msg">Уравнение сбалансировано.</div>'
|
||||
: '<div class="ceqb-msg">Не сходится — выровняйте выделенные элементы.</div>') +
|
||||
'<table class="ceqb-tab"><thead><tr><th>Элемент</th><th>Слева</th><th>Справа</th><th></th></tr></thead><tbody>' +
|
||||
rows + '</tbody></table>';
|
||||
return ok;
|
||||
}
|
||||
|
||||
var btnCheck = host.querySelector('[data-check]');
|
||||
var btnSolve = host.querySelector('[data-solve]');
|
||||
var btnReset = host.querySelector('[data-reset]');
|
||||
if (btnCheck) btnCheck.addEventListener('click', check);
|
||||
if (btnReset) btnReset.addEventListener('click', function () {
|
||||
for (var i = 0; i < coefs.length; i++) coefs[i].value = '1';
|
||||
out.className = 'ceqb-out'; out.innerHTML = '';
|
||||
});
|
||||
if (btnSolve && opts.solution) btnSolve.addEventListener('click', function () {
|
||||
for (var i = 0; i < coefs.length && i < opts.solution.length; i++) coefs[i].value = String(opts.solution[i]);
|
||||
check();
|
||||
});
|
||||
|
||||
return { el: host, check: check };
|
||||
}
|
||||
|
||||
/* 'H2 + O2' -> [{raw:'H2', counts:{H:2}}, {raw:'O2', counts:{O:2}}] */
|
||||
function parseSide(side) {
|
||||
return String(side).split('+').map(function (t) { return t.trim(); }).filter(Boolean)
|
||||
.map(function (raw) {
|
||||
var r = raw.replace(/^\d+/, '').trim(); // коэффициент в скелете игнорируем
|
||||
return { raw: r, counts: elementCounts(r) };
|
||||
});
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
testTube(opts) -> SVG-строка пробирки. opts: {fill, color, precipitate, gas,
|
||||
label}. fill/color — цвет раствора; precipitate — цвет осадка на дне;
|
||||
gas:true — пузырьки; label — подпись под пробиркой.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
function testTube(opts) {
|
||||
opts = opts || {};
|
||||
var liq = opts.color || opts.fill || '#dbeafe';
|
||||
var prec = opts.precipitate || null;
|
||||
var gas = !!opts.gas;
|
||||
var bubbles = '';
|
||||
if (gas) for (var i = 0; i < 5; i++) {
|
||||
var cx = 26 + (i % 3) * 7, cy = 60 - i * 8;
|
||||
bubbles += '<circle cx="' + cx + '" cy="' + cy + '" r="' + (1.6 + (i % 2)) + '" fill="rgba(255,255,255,.75)"><animate attributeName="cy" from="78" to="20" dur="' + (1.4 + i * .2) + 's" repeatCount="indefinite"/></circle>';
|
||||
}
|
||||
var precSvg = prec ? '<path d="M20 78 q12 7 24 0 l-2 6 q-10 5 -20 0 z" fill="' + prec + '"/>' : '';
|
||||
return '<svg class="tt-svg" viewBox="0 0 64 110" width="56" height="96">'
|
||||
+ '<defs><clipPath id="ttclip"><path d="M20 14 v60 a12 12 0 0 0 24 0 v-60"/></clipPath></defs>'
|
||||
+ '<rect x="20" y="38" width="24" height="46" fill="' + liq + '" clip-path="url(#ttclip)" opacity=".85"/>'
|
||||
+ precSvg
|
||||
+ '<g clip-path="url(#ttclip)">' + bubbles + '</g>'
|
||||
+ '<path d="M20 12 v62 a12 12 0 0 0 24 0 v-62" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>'
|
||||
+ '<line x1="17" y1="12" x2="47" y2="12" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>'
|
||||
+ (opts.label ? '<text x="32" y="104" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">' + escapeHtml(opts.label) + '</text>' : '')
|
||||
+ '</svg>';
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
indicatorScale(mount, opts) — индикатор + шкала pH. Слайдер pH 0–14,
|
||||
выбор индикатора (лакмус/фенолфталеин/метилоранж), окраска полоски.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var INDICATORS = {
|
||||
'лакмус': function (ph) { return ph < 5 ? ['#dc2626', 'красный (кислота)'] : ph > 8 ? ['#2563eb', 'синий (щёлочь)'] : ['#7c3aed', 'фиолетовый (нейтр.)']; },
|
||||
'фенолфталеин': function (ph) { return ph >= 8.2 ? ['#db2777', 'малиновый (щёлочь)'] : ['#f8fafc', 'бесцветный']; },
|
||||
'метилоранж': function (ph) { return ph < 3.1 ? ['#dc2626', 'красный (кислота)'] : ph > 4.4 ? ['#f59e0b', 'жёлтый'] : ['#fb923c', 'оранжевый']; }
|
||||
};
|
||||
function indicatorScale(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var inds = Object.keys(INDICATORS);
|
||||
host.innerHTML =
|
||||
'<div class="ind-row"><label>Индикатор</label><select class="ind-sel">' +
|
||||
inds.map(function (n) { return '<option value="' + n + '"' + (n === opts.indicator ? ' selected' : '') + '>' + n + '</option>'; }).join('') +
|
||||
'</select><label>pH</label><input type="range" class="ind-ph" min="0" max="14" step="0.5" value="' + (opts.ph != null ? opts.ph : 7) + '"><span class="ind-phv bd"></span></div>' +
|
||||
'<div class="ind-strip"></div><div class="ind-label"></div>';
|
||||
var sel = host.querySelector('.ind-sel'), ph = host.querySelector('.ind-ph'),
|
||||
phv = host.querySelector('.ind-phv'), strip = host.querySelector('.ind-strip'), lab = host.querySelector('.ind-label');
|
||||
function upd() {
|
||||
var v = parseFloat(ph.value), pair = INDICATORS[sel.value](v);
|
||||
phv.textContent = 'pH ' + String(v).replace('.', ',');
|
||||
strip.style.background = pair[0];
|
||||
strip.style.color = (pair[0] === '#f8fafc' || pair[0] === '#f59e0b') ? '#1c1917' : '#fff';
|
||||
strip.textContent = pair[1];
|
||||
lab.innerHTML = 'Среда: <b>' + (v < 7 ? 'кислая' : v > 7 ? 'щелочная' : 'нейтральная') + '</b> · ' + sel.value + ' → ' + pair[1];
|
||||
}
|
||||
sel.addEventListener('change', upd); ph.addEventListener('input', upd); upd();
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
classifier(mount, {items, buckets, onCheck}) — клик-классификатор (DnD без drag).
|
||||
items: [{id,label,cat}]; buckets: [{cat,label}]. Клик по чипу → выбран; клик
|
||||
по корзине → положить. «Проверить» подсвечивает верно/неверно. +XP по onCheck.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
function classifier(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {}; var items = opts.items || [], buckets = opts.buckets || [];
|
||||
var placed = {}; // id -> cat
|
||||
var sel = null;
|
||||
host.innerHTML =
|
||||
'<div class="cls-pool dnd-pool">' + items.map(function (it) {
|
||||
return '<button class="dnd-chip cls-chip" data-id="' + it.id + '">' + it.label + '</button>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="dnd-zones">' + buckets.map(function (b) {
|
||||
return '<div class="drop-box cls-zone" data-cat="' + b.cat + '"><h5>' + b.label + '</h5><div class="cls-items"></div></div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="ceqb-actions" style="margin-top:10px"><button class="ceqb-btn primary cls-check">Проверить</button><button class="ceqb-btn cls-reset">Сброс</button></div>' +
|
||||
'<div class="out cls-out" style="display:none"></div>';
|
||||
var out = host.querySelector('.cls-out');
|
||||
function findItem(id) { return items.filter(function (x) { return x.id === id; })[0]; }
|
||||
function selectChip(chip) {
|
||||
if (sel) sel.classList.remove('on'); sel = chip; chip.classList.add('on');
|
||||
}
|
||||
host.querySelectorAll('.cls-chip').forEach(function (chip) {
|
||||
chip.addEventListener('click', function () { selectChip(chip); });
|
||||
});
|
||||
host.querySelectorAll('.cls-zone').forEach(function (zone) {
|
||||
zone.addEventListener('click', function () {
|
||||
if (!sel) return;
|
||||
var id = sel.getAttribute('data-id');
|
||||
placed[id] = zone.getAttribute('data-cat');
|
||||
zone.querySelector('.cls-items').appendChild(sel);
|
||||
sel.classList.remove('on'); sel.classList.add('placed'); sel = null;
|
||||
});
|
||||
});
|
||||
host.querySelector('.cls-check').addEventListener('click', function () {
|
||||
var ok = 0, total = items.length;
|
||||
items.forEach(function (it) {
|
||||
var chip = host.querySelector('.cls-chip[data-id="' + it.id + '"]');
|
||||
var correct = placed[it.id] === it.cat;
|
||||
chip.classList.remove('cls-ok', 'cls-bad');
|
||||
chip.classList.add(correct ? 'cls-ok' : 'cls-bad');
|
||||
if (correct) ok++;
|
||||
});
|
||||
out.style.display = 'block';
|
||||
out.className = 'out cls-out ' + (ok === total ? 'ok' : 'bad');
|
||||
out.textContent = 'Верно: ' + ok + ' из ' + total + (ok === total ? '. Отлично!' : '. Исправь выделенные.');
|
||||
if (typeof opts.onCheck === 'function') opts.onCheck(ok === total, ok, total);
|
||||
});
|
||||
host.querySelector('.cls-reset').addEventListener('click', function () {
|
||||
placed = {}; sel = null;
|
||||
var pool = host.querySelector('.cls-pool');
|
||||
host.querySelectorAll('.cls-chip').forEach(function (c) { c.classList.remove('placed', 'on', 'cls-ok', 'cls-bad'); pool.appendChild(c); });
|
||||
out.style.display = 'none';
|
||||
});
|
||||
return { el: host, result: function () { return placed; } };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
solubilityTable(mount, opts) — таблица растворимости (катион×анион).
|
||||
Клик по катиону и аниону → подсветка ячейки + вердикт (Р/М/Н/—).
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var SOL_ANIONS = ['OH', 'Cl', 'NO3', 'SO4', 'CO3', 'PO4', 'S'];
|
||||
var SOL_CATIONS = ['Na', 'K', 'NH4', 'Ba', 'Ca', 'Mg', 'Al', 'Zn', 'Fe2', 'Fe3', 'Cu', 'Ag', 'Pb'];
|
||||
// P раств., M малораств., H нераств., '-' не существует/разлагается
|
||||
var SOL = {
|
||||
OH: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'M',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'-',Pb:'H'},
|
||||
Cl: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'H',Pb:'M'},
|
||||
NO3: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'P',Pb:'P'},
|
||||
SO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'M',Mg:'P',Al:'P',Zn:'P',Fe2:'P',Fe3:'P',Cu:'P',Ag:'M',Pb:'H'},
|
||||
CO3: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'},
|
||||
PO4: {Na:'P',K:'P',NH4:'P',Ba:'H',Ca:'H',Mg:'H',Al:'H',Zn:'H',Fe2:'H',Fe3:'H',Cu:'H',Ag:'H',Pb:'H'},
|
||||
S: {Na:'P',K:'P',NH4:'P',Ba:'P',Ca:'P',Mg:'P',Al:'-',Zn:'H',Fe2:'H',Fe3:'-',Cu:'H',Ag:'H',Pb:'H'}
|
||||
};
|
||||
var SOL_LABEL = { P: ['Р', 'растворимо'], M: ['М', 'малорастворимо'], H: ['Н', 'нерастворимо'], '-': ['—', 'не существует / разлагается'] };
|
||||
var CAT_HTML = { Na:'Na⁺', K:'K⁺', NH4:'NH₄⁺', Ba:'Ba²⁺', Ca:'Ca²⁺', Mg:'Mg²⁺', Al:'Al³⁺', Zn:'Zn²⁺', Fe2:'Fe²⁺', Fe3:'Fe³⁺', Cu:'Cu²⁺', Ag:'Ag⁺', Pb:'Pb²⁺' };
|
||||
var AN_HTML = { OH:'OH⁻', Cl:'Cl⁻', NO3:'NO₃⁻', SO4:'SO₄²⁻', CO3:'CO₃²⁻', PO4:'PO₄³⁻', S:'S²⁻' };
|
||||
function solubilityTable(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var th = '<tr><th>ион</th>' + SOL_CATIONS.map(function (c) { return '<th data-cat="' + c + '">' + CAT_HTML[c] + '</th>'; }).join('') + '</tr>';
|
||||
var rows = SOL_ANIONS.map(function (an) {
|
||||
return '<tr><th data-an="' + an + '">' + AN_HTML[an] + '</th>' + SOL_CATIONS.map(function (c) {
|
||||
var v = SOL[an][c]; var cls = v === 'P' ? 'sP' : v === 'M' ? 'sM' : v === 'H' ? 'sH' : 'sX';
|
||||
return '<td class="' + cls + '" data-an="' + an + '" data-cat="' + c + '">' + SOL_LABEL[v][0] + '</td>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
host.innerHTML = '<div class="sol-wrap"><table class="sol-tab"><thead>' + th + '</thead><tbody>' + rows + '</tbody></table></div>'
|
||||
+ '<div class="out sol-out">Кликни по катиону и аниону — узнаешь растворимость соли/основания.</div>';
|
||||
var out = host.querySelector('.sol-out'), selCat = null, selAn = null;
|
||||
function upd() {
|
||||
host.querySelectorAll('.sol-tab td').forEach(function (td) {
|
||||
var on = (!selCat || td.getAttribute('data-cat') === selCat) && (!selAn || td.getAttribute('data-an') === selAn);
|
||||
td.classList.toggle('sol-dim', (selCat || selAn) && !on);
|
||||
td.classList.toggle('sol-hot', selCat && selAn && td.getAttribute('data-cat') === selCat && td.getAttribute('data-an') === selAn);
|
||||
});
|
||||
if (selCat && selAn) {
|
||||
var v = SOL[selAn][selCat];
|
||||
out.className = 'out sol-out ' + (v === 'H' ? 'ok' : '');
|
||||
out.innerHTML = CAT_HTML[selCat] + ' + ' + AN_HTML[selAn] + ' → <b>' + SOL_LABEL[v][1] + '</b>' +
|
||||
(v === 'H' ? ' (выпадает осадок ↓ — реакция идёт)' : v === 'P' ? ' (осадок не образуется)' : '');
|
||||
}
|
||||
}
|
||||
host.querySelectorAll('[data-cat]').forEach(function (el) {
|
||||
if (el.tagName === 'TH') el.addEventListener('click', function () { selCat = el.getAttribute('data-cat'); upd(); });
|
||||
});
|
||||
host.querySelectorAll('th[data-an]').forEach(function (el) { el.addEventListener('click', function () { selAn = el.getAttribute('data-an'); upd(); }); });
|
||||
host.querySelectorAll('.sol-tab td').forEach(function (td) {
|
||||
td.addEventListener('click', function () { selCat = td.getAttribute('data-cat'); selAn = td.getAttribute('data-an'); upd(); });
|
||||
});
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
activitySeries(mount, opts) — ряд активности металлов. Клик по металлу →
|
||||
подсветка; показывает, какие металлы он вытесняет и реакцию с кислотой.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var ACT = ['K', 'Ca', 'Na', 'Mg', 'Al', 'Zn', 'Fe', 'Ni', 'Sn', 'Pb', 'H', 'Cu', 'Hg', 'Ag', 'Pt', 'Au'];
|
||||
function activitySeries(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
host.innerHTML = '<div class="act-row">' + ACT.map(function (m) {
|
||||
return '<button class="act-cell' + (m === 'H' ? ' act-h' : '') + '" data-m="' + m + '">' + (m === 'H' ? '(H₂)' : m) + '</button>';
|
||||
}).join('') + '</div><div class="act-axis"><span>← восстановит. свойства растут</span><span>активность падает →</span></div>'
|
||||
+ '<div class="out act-out">Кликни по металлу — узнаешь его активность и реакцию с кислотами.</div>';
|
||||
var out = host.querySelector('.act-out');
|
||||
host.querySelectorAll('.act-cell').forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
var m = c.getAttribute('data-m'); if (m === 'H') return;
|
||||
var idx = ACT.indexOf(m), hIdx = ACT.indexOf('H');
|
||||
host.querySelectorAll('.act-cell').forEach(function (x) { x.classList.remove('act-on', 'act-disp'); });
|
||||
c.classList.add('act-on');
|
||||
ACT.forEach(function (mm, i) { if (i > idx && mm !== 'H') host.querySelector('.act-cell[data-m="' + mm + '"]').classList.add('act-disp'); });
|
||||
var withAcid = idx < hIdx ? 'вытесняет водород $\\text{H}_2$ из растворов кислот' : 'НЕ вытесняет водород из кислот (стоит после H)';
|
||||
out.className = 'out act-out';
|
||||
out.innerHTML = '<b>' + m + '</b>: ' + withAcid + '. Вытесняет из растворов солей все металлы, стоящие <b>правее</b> (подсвечены).';
|
||||
if (global.window && global.window.chem8RenderMath) try { global.window.chem8RenderMath(out); } catch (e) {}
|
||||
});
|
||||
});
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
miniPeriodic(mount, opts) — интерактивная периодическая система.
|
||||
opts.highlight: 'metals'|'nonmetals'|'metalloids'|'alkali'|'alkaline'|
|
||||
'halogens'|'noble'|{group:N}|{period:N}. opts.onClick(sym, info).
|
||||
Стандартная раскладка 18×7; f-блок свёрнут в плейсхолдеры La и Ac.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
// [sym, group, period, Z]
|
||||
var PT = [
|
||||
['H',1,1,1],['He',18,1,2],
|
||||
['Li',1,2,3],['Be',2,2,4],['B',13,2,5],['C',14,2,6],['N',15,2,7],['O',16,2,8],['F',17,2,9],['Ne',18,2,10],
|
||||
['Na',1,3,11],['Mg',2,3,12],['Al',13,3,13],['Si',14,3,14],['P',15,3,15],['S',16,3,16],['Cl',17,3,17],['Ar',18,3,18],
|
||||
['K',1,4,19],['Ca',2,4,20],['Sc',3,4,21],['Ti',4,4,22],['V',5,4,23],['Cr',6,4,24],['Mn',7,4,25],['Fe',8,4,26],['Co',9,4,27],['Ni',10,4,28],['Cu',11,4,29],['Zn',12,4,30],['Ga',13,4,31],['Ge',14,4,32],['As',15,4,33],['Se',16,4,34],['Br',17,4,35],['Kr',18,4,36],
|
||||
['Rb',1,5,37],['Sr',2,5,38],['Y',3,5,39],['Zr',4,5,40],['Nb',5,5,41],['Mo',6,5,42],['Tc',7,5,43],['Ru',8,5,44],['Rh',9,5,45],['Pd',10,5,46],['Ag',11,5,47],['Cd',12,5,48],['In',13,5,49],['Sn',14,5,50],['Sb',15,5,51],['Te',16,5,52],['I',17,5,53],['Xe',18,5,54],
|
||||
['Cs',1,6,55],['Ba',2,6,56],['La',3,6,57],['Hf',4,6,72],['Ta',5,6,73],['W',6,6,74],['Re',7,6,75],['Os',8,6,76],['Ir',9,6,77],['Pt',10,6,78],['Au',11,6,79],['Hg',12,6,80],['Tl',13,6,81],['Pb',14,6,82],['Bi',15,6,83],['Po',16,6,84],['At',17,6,85],['Rn',18,6,86],
|
||||
['Cs',1,6,55]
|
||||
];
|
||||
// период 7 (главная часть)
|
||||
var PT7 = [['Fr',1,7,87],['Ra',2,7,88],['Ac',3,7,89],['Rf',4,7,104],['Db',5,7,105],['Sg',6,7,106],['Bh',7,7,107],['Hs',8,7,108],['Mt',9,7,109],['Ds',10,7,110],['Rg',11,7,111],['Cn',12,7,112],['Nh',13,7,113],['Fl',14,7,114],['Mc',15,7,115],['Lv',16,7,116],['Ts',17,7,117],['Og',18,7,118]];
|
||||
var PT_NAMES = { H:'Водород', He:'Гелий', Li:'Литий', Be:'Бериллий', B:'Бор', C:'Углерод', N:'Азот', O:'Кислород', F:'Фтор', Ne:'Неон', Na:'Натрий', Mg:'Магний', Al:'Алюминий', Si:'Кремний', P:'Фосфор', S:'Сера', Cl:'Хлор', Ar:'Аргон', K:'Калий', Ca:'Кальций', Fe:'Железо', Cu:'Медь', Zn:'Цинк', Br:'Бром', Ag:'Серебро', I:'Йод', Ba:'Барий', Au:'Золото', Hg:'Ртуть', Pb:'Свинец' };
|
||||
var NONMETALS = { H:1, He:1, C:1, N:1, O:1, F:1, Ne:1, P:1, S:1, Cl:1, Ar:1, Se:1, Br:1, Kr:1, I:1, Xe:1, At:1, Rn:1, Ts:1, Og:1 };
|
||||
var METALLOIDS = { B:1, Si:1, Ge:1, As:1, Sb:1, Te:1, Po:1 };
|
||||
function ptCategory(sym, g) {
|
||||
if (g === 18) return 'noble';
|
||||
if (METALLOIDS[sym]) return 'metalloid';
|
||||
if (NONMETALS[sym]) return 'nonmetal';
|
||||
return 'metal';
|
||||
}
|
||||
function ptMatch(hl, sym, g, p) {
|
||||
if (!hl) return false;
|
||||
if (typeof hl === 'object') { if (hl.group) return g === hl.group; if (hl.period) return p === hl.period; return false; }
|
||||
var cat = ptCategory(sym, g);
|
||||
if (hl === 'metals') return cat === 'metal';
|
||||
if (hl === 'nonmetals') return cat === 'nonmetal';
|
||||
if (hl === 'metalloids') return cat === 'metalloid';
|
||||
if (hl === 'noble') return g === 18;
|
||||
if (hl === 'halogens') return g === 17;
|
||||
if (hl === 'alkali') return g === 1 && sym !== 'H';
|
||||
if (hl === 'alkaline') return g === 2;
|
||||
return false;
|
||||
}
|
||||
function miniPeriodic(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var all = PT.slice(0, PT.length - 1).concat(PT7); // убрать дубль Cs-стоппер
|
||||
// фильтр дубликата Cs (вставлен как маркер конца) — оставляем уникальные по Z
|
||||
var seen = {}, els = [];
|
||||
all.forEach(function (e) { if (!seen[e[3]]) { seen[e[3]] = 1; els.push(e); } });
|
||||
function cell(e) {
|
||||
var sym = e[0], g = e[1], p = e[2], z = e[3], cat = ptCategory(sym, g);
|
||||
var hot = ptMatch(opts.highlight, sym, g, p);
|
||||
return '<button class="pt-cell pt-' + cat + (hot ? ' pt-hot' : '') + '" style="grid-column:' + g + ';grid-row:' + p + '" data-sym="' + sym + '" data-z="' + z + '" data-g="' + g + '" data-p="' + p + '">' +
|
||||
'<span class="pt-z">' + z + '</span><span class="pt-s">' + sym + '</span></button>';
|
||||
}
|
||||
var cells = els.map(cell).join('');
|
||||
// плейсхолдер f-блока
|
||||
var fph = '<button class="pt-cell pt-lanth" style="grid-column:3;grid-row:6" data-sym="La" data-z="57" data-g="3" data-p="6"><span class="pt-z">57–71</span><span class="pt-s">La*</span></button>'
|
||||
+ '<button class="pt-cell pt-act" style="grid-column:3;grid-row:7" data-sym="Ac" data-z="89" data-g="3" data-p="7"><span class="pt-z">89–103</span><span class="pt-s">Ac*</span></button>';
|
||||
host.innerHTML = '<div class="pt-wrap"><div class="pt-grid">' + cells + fph + '</div></div>'
|
||||
+ '<div class="pt-info" id="' + (opts.infoId || 'pt-info') + '">Кликни элемент — увидишь название, $Z$ и $A_r$.</div>';
|
||||
var info = host.querySelector('.pt-info');
|
||||
host.querySelectorAll('.pt-cell').forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
host.querySelectorAll('.pt-cell').forEach(function (x) { x.classList.remove('pt-sel'); });
|
||||
c.classList.add('pt-sel');
|
||||
var sym = c.getAttribute('data-sym'), z = c.getAttribute('data-z'), g = +c.getAttribute('data-g'), p = +c.getAttribute('data-p');
|
||||
var ar = arOf(sym), cat = ptCategory(sym, g);
|
||||
var catRu = cat === 'metal' ? 'металл' : cat === 'nonmetal' ? 'неметалл' : cat === 'metalloid' ? 'металлоид' : 'инертный газ';
|
||||
var fam = g === 1 && sym !== 'H' ? ' · щелочной металл' : g === 2 ? ' · щёлочноземельный' : g === 17 ? ' · галоген' : g === 18 ? ' · инертный газ' : '';
|
||||
info.innerHTML = '<b>' + (PT_NAMES[sym] || sym) + '</b> (' + sym + ') · Z = ' + z + (ar ? ' · A_r = ' + ar : '') + ' · группа ' + g + ', период ' + p + ' · ' + catRu + fam;
|
||||
if (typeof opts.onClick === 'function') opts.onClick(sym, { z: z, g: g, p: p, ar: ar, cat: cat });
|
||||
});
|
||||
});
|
||||
return {
|
||||
el: host,
|
||||
highlight: function (hl) {
|
||||
host.querySelectorAll('.pt-cell').forEach(function (c) {
|
||||
c.classList.toggle('pt-hot', ptMatch(hl, c.getAttribute('data-sym'), +c.getAttribute('data-g'), +c.getAttribute('data-p')));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Строение атома (Phase 4).
|
||||
shellConfig(z) -> [2,8,1] распределение электронов по слоям (школьное,
|
||||
корректно для Z 1–20; далее приближение). zSym(z) -> символ из ПСХЭ.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var _ZSYM = null;
|
||||
function zSym(z) {
|
||||
if (!_ZSYM) { _ZSYM = {}; PT.concat(PT7).forEach(function (e) { _ZSYM[e[3]] = e[0]; }); }
|
||||
return _ZSYM[z] || '?';
|
||||
}
|
||||
function shellConfig(z) {
|
||||
var caps = [2, 8, 8, 18, 18, 32], out = [], rem = z;
|
||||
for (var i = 0; i < caps.length && rem > 0; i++) { var t = Math.min(caps[i], rem); out.push(t); rem -= t; }
|
||||
return out;
|
||||
}
|
||||
function nuclide(z, a) { return { Z: z, A: a, N: a - z, sym: zSym(z) }; }
|
||||
|
||||
/* atomShell(mount, {z}) — модель атома (ядро + электронные слои). Слайдер Z 1–20. */
|
||||
function atomShell(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
host.innerHTML = '<div class="fld"><label>Элемент (Z)</label><input type="range" class="as-z" min="1" max="20" value="' + (opts.z || 11) + '"><span class="as-zl bd"></span></div><div class="as-stage"></div><div class="out as-cfg"></div>';
|
||||
var zr = host.querySelector('.as-z'), zl = host.querySelector('.as-zl'), stage = host.querySelector('.as-stage'), cfg = host.querySelector('.as-cfg');
|
||||
function draw() {
|
||||
var z = +zr.value, sym = zSym(z), ar = arOf(sym), n = Math.max(0, Math.round(ar) - z), sh = shellConfig(z);
|
||||
zl.textContent = sym + ' (Z=' + z + ')';
|
||||
var cx = 150, cy = 110, R = 18 + sh.length * 26;
|
||||
var svg = '<svg viewBox="0 0 300 ' + (cy * 2) + '" class="as-svg">';
|
||||
// слои
|
||||
for (var s = 0; s < sh.length; s++) {
|
||||
var r = 30 + s * 26;
|
||||
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="none" stroke="currentColor" stroke-width="1" opacity=".35"/>';
|
||||
var cnt = sh[s];
|
||||
for (var e = 0; e < cnt; e++) {
|
||||
var ang = (e / cnt) * Math.PI * 2 - Math.PI / 2;
|
||||
var ex = cx + r * Math.cos(ang), ey = cy + r * Math.sin(ang);
|
||||
svg += '<circle cx="' + ex.toFixed(1) + '" cy="' + ey.toFixed(1) + '" r="4" fill="var(--pri)"/>';
|
||||
}
|
||||
}
|
||||
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="18" fill="var(--pri)" opacity=".18" stroke="var(--pri)" stroke-width="1.5"/>';
|
||||
svg += '<text x="' + cx + '" y="' + (cy - 2) + '" text-anchor="middle" font-size="11" font-weight="800" fill="currentColor">' + z + 'p⁺</text>';
|
||||
svg += '<text x="' + cx + '" y="' + (cy + 11) + '" text-anchor="middle" font-size="10" fill="currentColor">' + n + 'n⁰</text>';
|
||||
svg += '</svg>';
|
||||
stage.innerHTML = svg;
|
||||
cfg.className = 'out as-cfg';
|
||||
cfg.innerHTML = '<span class="bd"><b>' + sym + '</b>: распределение электронов по слоям — ' + sh.join(' ) ') + '<br>Слоёв: ' + sh.length + ' · внешних электронов: ' + sh[sh.length - 1] + ' · протонов: ' + z + ', нейтронов: ' + n + '</span>';
|
||||
}
|
||||
zr.addEventListener('input', draw); draw();
|
||||
return { el: host, draw: draw };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Химическая связь (Phase 5).
|
||||
EN — электроотрицательность (Полинг, школьные значения). bondClass(da,db)
|
||||
по разнице ЭО → тип связи. bondType(mount) — интерактивный виджет.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var EN = {
|
||||
H:2.1, Li:1.0, Be:1.5, B:2.0, C:2.5, N:3.0, O:3.5, F:4.0,
|
||||
Na:0.9, Mg:1.2, Al:1.5, Si:1.8, P:2.1, S:2.5, Cl:3.0,
|
||||
K:0.8, Ca:1.0, Br:2.8, I:2.5, Zn:1.6, Fe:1.8, Cu:1.9, Ag:1.9
|
||||
};
|
||||
function enOf(sym) { return EN[sym] != null ? EN[sym] : 2.0; }
|
||||
function bondClass(a, b) {
|
||||
var d = Math.abs(enOf(a) - enOf(b));
|
||||
if (a !== b && (a in EN) && (b in EN) && enOf(a) <= 1.6 && enOf(b) <= 1.6) {
|
||||
// два металла → металлическая
|
||||
if (METALS_EN[a] && METALS_EN[b]) return { type: 'металлическая', cls: 'warn', d: d };
|
||||
}
|
||||
if (d >= 1.7) return { type: 'ионная', cls: 'bad', d: d };
|
||||
if (d < 0.4) return { type: 'ковалентная неполярная', cls: 'good', d: d };
|
||||
return { type: 'ковалентная полярная', cls: 'mid', d: d };
|
||||
}
|
||||
var METALS_EN = { Li:1, Be:1, Na:1, Mg:1, Al:1, K:1, Ca:1, Zn:1, Fe:1, Cu:1, Ag:1 };
|
||||
|
||||
function bondType(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var syms = Object.keys(EN);
|
||||
function optList(sel) { return syms.map(function (s) { return '<option value="' + s + '"' + (s === sel ? ' selected' : '') + '>' + s + ' (ЭО ' + enOf(s) + ')</option>'; }).join(''); }
|
||||
host.innerHTML = '<div class="fld"><label>Атом A</label><select class="bt-a">' + optList(opts.a || 'H') + '</select>'
|
||||
+ '<label>Атом B</label><select class="bt-b">' + optList(opts.b || 'Cl') + '</select></div>'
|
||||
+ '<div class="bt-stage"></div><div class="out bt-out"></div>';
|
||||
var sa = host.querySelector('.bt-a'), sb = host.querySelector('.bt-b'), stage = host.querySelector('.bt-stage'), out = host.querySelector('.bt-out');
|
||||
function upd() {
|
||||
var a = sa.value, b = sb.value, r = bondClass(a, b), d = Math.round(r.d * 10) / 10;
|
||||
// δ-заряды: более ЭО атом — δ−
|
||||
var aMore = enOf(a) > enOf(b), polar = r.type.indexOf('полярная') >= 0;
|
||||
var da = (r.type === 'ионная') ? (aMore ? '−' : '+') : (polar ? (aMore ? 'δ−' : 'δ+') : '');
|
||||
var db = (r.type === 'ионная') ? (aMore ? '+' : '−') : (polar ? (aMore ? 'δ+' : 'δ−') : '');
|
||||
var color = r.cls === 'good' ? 'var(--ok)' : r.cls === 'bad' ? 'var(--fail)' : 'var(--pri)';
|
||||
stage.innerHTML = '<svg viewBox="0 0 240 70" class="bt-svg">'
|
||||
+ '<line x1="95" y1="35" x2="145" y2="35" stroke="' + color + '" stroke-width="3"/>'
|
||||
+ '<circle cx="80" cy="35" r="26" fill="' + color + '" opacity=".15" stroke="' + color + '" stroke-width="2"/>'
|
||||
+ '<circle cx="160" cy="35" r="26" fill="' + color + '" opacity=".15" stroke="' + color + '" stroke-width="2"/>'
|
||||
+ '<text x="80" y="40" text-anchor="middle" font-size="16" font-weight="800" fill="currentColor">' + a + '</text>'
|
||||
+ '<text x="160" y="40" text-anchor="middle" font-size="16" font-weight="800" fill="currentColor">' + b + '</text>'
|
||||
+ (da ? '<text x="80" y="12" text-anchor="middle" font-size="12" font-weight="800" fill="' + color + '">' + da + '</text>' : '')
|
||||
+ (db ? '<text x="160" y="12" text-anchor="middle" font-size="12" font-weight="800" fill="' + color + '">' + db + '</text>' : '')
|
||||
+ '</svg>';
|
||||
out.className = 'out bt-out ' + (r.cls === 'good' ? 'ok' : r.cls === 'bad' ? 'bad' : '');
|
||||
out.innerHTML = '<span class="bd">ΔЭО = |' + enOf(a) + ' − ' + enOf(b) + '| = <b>' + d + '</b> → связь <b>' + r.type + '</b>'
|
||||
+ (r.type === 'ионная' ? '<br>Электрон полностью переходит к более электроотрицательному атому.' : polar ? '<br>Общая пара смещена к более электроотрицательному атому (' + (aMore ? a : b) + ').' : r.type.indexOf('металл') >= 0 ? '<br>Общие электроны принадлежат всем атомам («электронный газ»).' : '<br>Общая пара поделена поровну.') + '</span>';
|
||||
}
|
||||
sa.addEventListener('change', upd); sb.addEventListener('change', upd); upd();
|
||||
return { el: host, update: upd };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Степень окисления (Phase 6).
|
||||
oxStates(formula) -> {el: oxidation} для типичных нейтральных соединений.
|
||||
Правила: F=−1, O=−2, H=+1, щелочные=+1, ЩЗМ=+2, Al=+3; галогены=−1 без O;
|
||||
остаток решается из условия Σ(с.о.·индекс)=0. oxStateCalc — виджет.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var OX_FIX = { F:-1, O:-2, H:1, Li:1, Na:1, K:1, Rb:1, Cs:1, Be:2, Mg:2, Ca:2, Sr:2, Ba:2, Al:3, Zn:2, Ag:1 };
|
||||
function oxStates(formula) {
|
||||
var c = elementCounts(String(formula || '').replace(/\s+/g, ''));
|
||||
var keys = Object.keys(c); if (!keys.length) return null;
|
||||
var hasO = !!c.O, res = {}, unknown = [], sumFixed = 0;
|
||||
keys.forEach(function (el) {
|
||||
var v;
|
||||
if (Object.prototype.hasOwnProperty.call(OX_FIX, el)) v = OX_FIX[el];
|
||||
else if ((el === 'Cl' || el === 'Br' || el === 'I') && !hasO) v = -1;
|
||||
else { unknown.push(el); return; }
|
||||
res[el] = v; sumFixed += v * c[el];
|
||||
});
|
||||
if (unknown.length === 1) {
|
||||
var el = unknown[0];
|
||||
res[el] = -sumFixed / c[el];
|
||||
} else if (unknown.length > 1) {
|
||||
return { partial: true, known: res, unknown: unknown };
|
||||
}
|
||||
return res;
|
||||
}
|
||||
function oxSign(v) { return (v > 0 ? '+' : v < 0 ? '−' : '') + Math.abs(v); }
|
||||
function oxStateCalc(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
host.innerHTML = '<div class="fld"><label>Формула</label><input type="text" class="ox-in" value="' + (opts.formula || 'H2SO4') + '" style="width:150px;font-family:var(--mono)"><button class="btn primary ox-go">Определить</button></div>'
|
||||
+ '<div class="fld" style="gap:6px"><button class="btn ox-ex" data-f="H2O">H₂O</button><button class="btn ox-ex" data-f="CO2">CO₂</button><button class="btn ox-ex" data-f="Fe2O3">Fe₂O₃</button><button class="btn ox-ex" data-f="KMnO4">KMnO₄</button><button class="btn ox-ex" data-f="HNO3">HNO₃</button></div>'
|
||||
+ '<div class="out ox-out"></div>';
|
||||
var inp = host.querySelector('.ox-in'), out = host.querySelector('.ox-out'), go = host.querySelector('.ox-go');
|
||||
function calc() {
|
||||
var f = inp.value.trim(), r = oxStates(f);
|
||||
if (!r) { out.className = 'out ox-out bad'; out.textContent = 'Не удалось разобрать формулу.'; return; }
|
||||
if (r.partial) {
|
||||
out.className = 'out ox-out bad';
|
||||
out.innerHTML = 'Несколько неизвестных элементов (' + r.unknown.join(', ') + ') — для 8 класса возьми более простое соединение.';
|
||||
return;
|
||||
}
|
||||
out.className = 'out ox-out ok';
|
||||
out.innerHTML = '<span class="bd">' + Object.keys(r).map(function (el) { return el + ': <b>' + oxSign(r[el]) + '</b>'; }).join(' ') + '</span>';
|
||||
}
|
||||
go.addEventListener('click', calc);
|
||||
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') calc(); });
|
||||
host.querySelectorAll('.ox-ex').forEach(function (b) { b.addEventListener('click', function () { inp.value = b.dataset.f; calc(); }); });
|
||||
calc();
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
geneticMap(mount) — интерактивный граф генетической связи классов веществ.
|
||||
Клик по переходу (ребру) → реакция-пример. §22.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var GM_NODES = [
|
||||
{ id: 'me', t: 'Металл', x: 20, y: 22, c: '#0d9488' },
|
||||
{ id: 'mox', t: 'Осн. оксид', x: 120, y: 22, c: '#0d9488' },
|
||||
{ id: 'base', t: 'Основание', x: 228, y: 22, c: '#0d9488' },
|
||||
{ id: 'salt', t: 'Соль', x: 336, y: 55, c: '#d97706' },
|
||||
{ id: 'nm', t: 'Неметалл', x: 20, y: 90, c: '#2563eb' },
|
||||
{ id: 'nox', t: 'Кисл. оксид', x: 120, y: 90, c: '#2563eb' },
|
||||
{ id: 'acid', t: 'Кислота', x: 228, y: 90, c: '#2563eb' }
|
||||
];
|
||||
var GM_EDGES = [
|
||||
{ f: 'me', t: 'mox', r: '2Mg + O2 -> 2MgO', d: 'Металл + кислород → основный оксид' },
|
||||
{ f: 'mox', t: 'base', r: 'CaO + H2O -> Ca(OH)2', d: 'Основный оксид + вода → основание (щёлочь)' },
|
||||
{ f: 'base', t: 'salt', r: '2NaOH + H2SO4 -> Na2SO4 + 2H2O', d: 'Основание + кислота → соль + вода (нейтрализация)' },
|
||||
{ f: 'nm', t: 'nox', r: 'S + O2 -> SO2', d: 'Неметалл + кислород → кислотный оксид' },
|
||||
{ f: 'nox', t: 'acid', r: 'SO3 + H2O -> H2SO4', d: 'Кислотный оксид + вода → кислота' },
|
||||
{ f: 'acid', t: 'salt', r: '2HCl + Ca(OH)2 -> CaCl2 + 2H2O', d: 'Кислота + основание → соль + вода' }
|
||||
];
|
||||
function geneticMap(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
var byId = {}; GM_NODES.forEach(function (n) { byId[n.id] = n; });
|
||||
function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; }
|
||||
var edgesSvg = GM_EDGES.map(function (e, i) {
|
||||
var a = byId[e.f], b = byId[e.t];
|
||||
return '<line class="gm-edge" data-i="' + i + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '" stroke="var(--muted,#888)" stroke-width="2.5"/>';
|
||||
}).join('');
|
||||
var nodesSvg = GM_NODES.map(function (n) {
|
||||
return '<g><rect x="' + n.x + '" y="' + n.y + '" width="88" height="32" rx="8" fill="' + n.c + '" opacity=".16" stroke="' + n.c + '" stroke-width="1.5"/>'
|
||||
+ '<text x="' + cx(n) + '" y="' + (cy(n) + 4) + '" text-anchor="middle" font-size="11" font-weight="800" fill="currentColor">' + n.t + '</text></g>';
|
||||
}).join('');
|
||||
host.innerHTML = '<div class="gm-wrap"><svg viewBox="0 0 430 130" class="gm-svg">' + edgesSvg + nodesSvg + '</svg></div>'
|
||||
+ '<div class="out gm-out">Кликни по стрелке-переходу — увидишь реакцию-пример.</div>';
|
||||
var out = host.querySelector('.gm-out');
|
||||
host.querySelectorAll('.gm-edge').forEach(function (ln) {
|
||||
ln.style.cursor = 'pointer';
|
||||
ln.addEventListener('click', function () {
|
||||
host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); });
|
||||
ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4');
|
||||
var e = GM_EDGES[+ln.getAttribute('data-i')];
|
||||
out.className = 'out gm-out ok';
|
||||
out.innerHTML = '<b>' + e.d + '</b><br><span class="bd">' + chemEq(e.r) + '</span>';
|
||||
});
|
||||
});
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
conceptMap(mount, {nodes, edges}) — обобщённая карта связей понятий главы.
|
||||
nodes: [{id, t, x, y, c?}]; edges: [{f, t, label}]. Клик по ребру → подпись.
|
||||
Используется в финалах глав (U6).
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
function conceptMap(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host || !opts) return null;
|
||||
var nodes = opts.nodes || [], edges = opts.edges || [];
|
||||
var byId = {}; nodes.forEach(function (n) { byId[n.id] = n; });
|
||||
var W0 = opts.w || 430, H0 = opts.h || 150;
|
||||
function cx(n) { return n.x + 44; } function cy(n) { return n.y + 16; }
|
||||
var edgesSvg = edges.map(function (e, i) {
|
||||
var a = byId[e.f], b = byId[e.t]; if (!a || !b) return '';
|
||||
return '<line class="gm-edge" data-i="' + i + '" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '" stroke="var(--muted,#888)" stroke-width="2.5"/>';
|
||||
}).join('');
|
||||
var nodesSvg = nodes.map(function (n) {
|
||||
var c = n.c || 'var(--pri,#d97706)';
|
||||
return '<g><rect x="' + n.x + '" y="' + n.y + '" width="88" height="32" rx="8" fill="' + c + '" opacity=".16" stroke="' + c + '" stroke-width="1.5"/>'
|
||||
+ '<text x="' + cx(n) + '" y="' + (cy(n) + 4) + '" text-anchor="middle" font-size="10.5" font-weight="800" fill="currentColor">' + n.t + '</text></g>';
|
||||
}).join('');
|
||||
host.innerHTML = '<div class="gm-wrap"><svg viewBox="0 0 ' + W0 + ' ' + H0 + '" class="gm-svg">' + edgesSvg + nodesSvg + '</svg></div>'
|
||||
+ '<div class="out gm-out">Кликни по связи — увидишь, как понятия главы связаны.</div>';
|
||||
var out = host.querySelector('.gm-out');
|
||||
host.querySelectorAll('.gm-edge').forEach(function (ln) {
|
||||
ln.style.cursor = 'pointer';
|
||||
ln.addEventListener('click', function () {
|
||||
host.querySelectorAll('.gm-edge').forEach(function (x) { x.setAttribute('stroke', 'var(--muted,#888)'); x.setAttribute('stroke-width', '2.5'); });
|
||||
ln.setAttribute('stroke', 'var(--pri,#d97706)'); ln.setAttribute('stroke-width', '4');
|
||||
out.className = 'out gm-out ok'; out.innerHTML = '<b>' + edges[+ln.getAttribute('data-i')].label + '</b>';
|
||||
});
|
||||
});
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
dissociationAnim(mount, {substance}) — анимация растворения/диссоциации:
|
||||
вещество распадается на ионы, окружённые молекулами воды. §47.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
var DISS = {
|
||||
NaCl: { cat: 'Na⁺', an: 'Cl⁻', cc: '#d97706', ac: '#0891b2' },
|
||||
KCl: { cat: 'K⁺', an: 'Cl⁻', cc: '#7c3aed', ac: '#0891b2' },
|
||||
CuSO4: { cat: 'Cu²⁺', an: 'SO₄²⁻', cc: '#0891b2', ac: '#059669' },
|
||||
HCl: { cat: 'H⁺', an: 'Cl⁻', cc: '#dc2626', ac: '#0891b2' }
|
||||
};
|
||||
function dissociationAnim(mount, opts) {
|
||||
var host = typeof mount === 'string' ? global.document.querySelector(mount) : mount;
|
||||
if (!host) return null;
|
||||
opts = opts || {};
|
||||
var subs = Object.keys(DISS);
|
||||
host.innerHTML = '<div class="fld"><label>Вещество</label><select class="ds-sel">' +
|
||||
subs.map(function (s) { return '<option value="' + s + '"' + (s === opts.substance ? ' selected' : '') + '>' + formula(s) + '</option>'; }).join('') + '</select></div>'
|
||||
+ '<div class="ds-stage"></div><div class="out ds-out"></div>';
|
||||
var sel = host.querySelector('.ds-sel'), stage = host.querySelector('.ds-stage'), out = host.querySelector('.ds-out');
|
||||
function draw() {
|
||||
var s = sel.value, d = DISS[s];
|
||||
// молекулы воды (фон) + катион + анион, разлетающиеся
|
||||
var water = '';
|
||||
for (var i = 0; i < 7; i++) { var wx = 30 + i * 35, wy = 25 + (i % 3) * 30; water += '<circle cx="' + wx + '" cy="' + wy + '" r="3" fill="#60a5fa" opacity=".5"/>'; }
|
||||
stage.innerHTML = '<svg viewBox="0 0 270 100" class="ds-svg">'
|
||||
+ '<rect x="6" y="6" width="258" height="88" rx="12" fill="#0891b2" opacity=".07" stroke="#0891b2" stroke-width="1.5"/>' + water
|
||||
+ '<circle cx="135" cy="50" r="17" fill="' + d.cc + '" opacity=".85"><animate attributeName="cx" values="135;70;135" dur="3s" repeatCount="indefinite"/></circle>'
|
||||
+ '<text x="135" y="55" text-anchor="middle" font-size="11" font-weight="800" fill="#fff"><animate attributeName="x" values="135;70;135" dur="3s" repeatCount="indefinite"/>' + d.cat + '</text>'
|
||||
+ '<circle cx="135" cy="50" r="17" fill="' + d.ac + '" opacity=".85"><animate attributeName="cx" values="135;200;135" dur="3s" repeatCount="indefinite"/></circle>'
|
||||
+ '<text x="135" y="55" text-anchor="middle" font-size="10" font-weight="800" fill="#fff"><animate attributeName="x" values="135;200;135" dur="3s" repeatCount="indefinite"/>' + d.an + '</text>'
|
||||
+ '</svg>';
|
||||
out.className = 'out ds-out ok';
|
||||
out.innerHTML = '<span class="bd">' + formula(s) + ' → ' + d.cat + ' + ' + d.an + '<br>Молекулы воды окружают ионы и «растаскивают» их (гидратация).</span>';
|
||||
}
|
||||
sel.addEventListener('change', draw); draw();
|
||||
return { el: host };
|
||||
}
|
||||
|
||||
/* ---- Каркасы-заглушки интерактивных виджетов (реализуются по фазам) ---- */
|
||||
function notImplemented(name) {
|
||||
return function () {
|
||||
if (global.console && console.warn) {
|
||||
console.warn('[Chem8] ' + name + ' ещё не реализован (Phase 0 заглушка)');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
var Chem8 = {
|
||||
// готово (Phase 0)
|
||||
formula: formula,
|
||||
ionLabel: ionLabel,
|
||||
chemEq: chemEq,
|
||||
toSub: toSub,
|
||||
toSup: toSup,
|
||||
// готово (Phase 1 — движки расчётов)
|
||||
elementCounts: elementCounts,
|
||||
molarMass: molarMass, // school-rounded Ar: Mr(H2O)=18
|
||||
arOf: arOf,
|
||||
fmt: fmt,
|
||||
moleTriangle: moleTriangle, // §6 — треугольник n–m–M
|
||||
equationBalancer: equationBalancer, // §8 — балансировщик уравнений
|
||||
// готово (Phase 2 — классы неорганических соединений)
|
||||
testTube: testTube, // §18,25 — пробирка: осадок/газ/окраска
|
||||
indicatorScale: indicatorScale, // §13,14,16,17 — индикатор + шкала pH
|
||||
classifier: classifier, // §10,13,16,19 — клик-классификатор
|
||||
solubilityTable: solubilityTable, // §19,20 — таблица растворимости
|
||||
activitySeries: activitySeries, // §14,20 — ряд активности металлов
|
||||
// готово (Phase 3 — периодический закон)
|
||||
miniPeriodic: miniPeriodic, // §26,28,34 — интерактивная ПСХЭ с подсветкой
|
||||
// готово (Phase 4 — строение атома)
|
||||
atomShell: atomShell, // §29,33 — модель атома (слои электронов)
|
||||
shellConfig: shellConfig, // распределение электронов по слоям
|
||||
nuclide: nuclide, // §30 — A=Z+N, нуклид
|
||||
zSym: zSym, // Z → символ элемента
|
||||
// готово (Phase 5 — химическая связь)
|
||||
bondType: bondType, // §37,38 — ЭО → тип связи
|
||||
bondClass: bondClass, // классификация связи по ΔЭО
|
||||
enOf: enOf, // электроотрицательность
|
||||
// готово (Phase 6 — ОВР)
|
||||
oxStateCalc: oxStateCalc, // §42 — калькулятор степени окисления
|
||||
oxStates: oxStates, // степени окисления (чистая функция)
|
||||
// готово (Phase 8/U3,U6 — апгрейд)
|
||||
geneticMap: geneticMap, // §22 — генетическая карта-граф классов
|
||||
conceptMap: conceptMap, // финалы глав — карта связей понятий (U6)
|
||||
dissociationAnim: dissociationAnim, // §47 — анимация растворения/диссоциации
|
||||
// редокс-баланс §44 реализован пошагово в chem8_ch5_widgets (преднабор)
|
||||
redoxBalancer: notImplemented('redoxBalancer'),
|
||||
orbitalDiagram: notImplemented('orbitalDiagram') // §33 — покрыто atomShell
|
||||
};
|
||||
|
||||
global.Chem8 = Chem8;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LabLoader — ленивый загрузчик кода симуляций (контент-движок, Фаза 3).
|
||||
*
|
||||
* Тяжёлый код симуляций (~2.5 МБ) и three.js (~600 КБ) больше НЕ грузятся на старте.
|
||||
* При открытии симуляции LabLoader.ensure(id) подгружает её файлы (по манифесту
|
||||
* window.SIM_DEPS из _sim_deps.js) и, при необходимости, three.js — затем резолвит.
|
||||
*
|
||||
* Гарантия корректности (самовосстановление): если после загрузки указанных файлов
|
||||
* глобальная open-функция (SIM_DEPS[id].open, напр. "_openPendulum") всё ещё не
|
||||
* определена, грузятся ВСЕ ленивые файлы (window.LAB_LAZY_FILES). Поэтому ошибка в
|
||||
* манифесте не может «сломать» симуляцию — в худшем случае грузится больше файлов
|
||||
* (поведение как до Фазы 3). Манифест лишь оптимизирует объём загрузки.
|
||||
*
|
||||
* Все загрузки кешируются (по URL) и дедуплицируются.
|
||||
*/
|
||||
(function () {
|
||||
var BASE = '/js/labs/';
|
||||
var THREE_URL = 'https://cdn.jsdelivr.net/npm/three@0.149.0/build/three.min.js';
|
||||
var _cache = {}; // url -> Promise
|
||||
var _allLoaded = false;
|
||||
|
||||
function loadScript(url) {
|
||||
if (_cache[url]) return _cache[url];
|
||||
_cache[url] = new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.async = false; // сохранить порядок при добавлении нескольких сразу
|
||||
s.onload = function () { resolve(url); };
|
||||
s.onerror = function () { delete _cache[url]; reject(new Error('LabLoader: не удалось загрузить ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _cache[url];
|
||||
}
|
||||
|
||||
function ensureThree() {
|
||||
if (typeof window.THREE !== 'undefined') return Promise.resolve();
|
||||
return loadScript(THREE_URL);
|
||||
}
|
||||
|
||||
function loadFiles(files) {
|
||||
return Promise.all((files || []).map(function (f) { return loadScript(BASE + f); }));
|
||||
}
|
||||
|
||||
function loadAllLazy() {
|
||||
if (_allLoaded) return Promise.resolve();
|
||||
var list = window.LAB_LAZY_FILES || [];
|
||||
return loadFiles(list).then(function () { _allLoaded = true; });
|
||||
}
|
||||
|
||||
// ensure(id): загрузить всё необходимое для симуляции id, вернуть Promise.
|
||||
function ensure(id) {
|
||||
var dep = (window.SIM_DEPS && window.SIM_DEPS[id]) || null;
|
||||
if (!dep) {
|
||||
// нет манифеста для id — безопасно грузим всё
|
||||
return loadAllLazy();
|
||||
}
|
||||
var p = dep.three ? ensureThree() : Promise.resolve();
|
||||
return p
|
||||
.then(function () { return loadFiles(dep.files); })
|
||||
.then(function () {
|
||||
var openName = dep.open;
|
||||
if (openName && typeof window[openName] !== 'function') {
|
||||
if (window.console) console.warn('[LabLoader] самовосстановление для "' + id + '": ' + openName + ' не найдена после загрузки ' + JSON.stringify(dep.files) + ' — гружу все ленивые файлы');
|
||||
return loadAllLazy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.LabLoader = {
|
||||
ensure: ensure,
|
||||
ensureThree: ensureThree,
|
||||
loadScript: loadScript,
|
||||
loadAllLazy: loadAllLazy
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Контент-движок, Фаза 1 — data-driven регистрация ВСЕХ симуляций в LabRegistry.
|
||||
*
|
||||
* Вместо ручного переписывания 40 манифестов модуль строит их из единых источников:
|
||||
* - метаданные (id/cat/title/desc) и preview — из массива SIMS (lab-glue.js);
|
||||
* - теория — из объекта THEORY (lab-init.js);
|
||||
* - поведение open(ctx) — из карты OPEN ниже (обёртки над глобальными _openXxx).
|
||||
* Это структурно гарантирует паритет с прежним каталогом и диспетчеризацией.
|
||||
*
|
||||
* Подключается ПОСЛЕДНИМ среди labs-скриптов (defer), поэтому SIMS, THEORY и все
|
||||
* _openXxx уже определены. Останов/закрытие симуляций по-прежнему выполняет
|
||||
* «дробовик» _pauseAllSims()/closeSim() (точный паритет) — поэтому stop/destroy
|
||||
* в манифестах не задаются на этом этапе.
|
||||
*
|
||||
* После регистрации if-цепочка в openSim() становится мёртвой и удалена.
|
||||
*
|
||||
* В Фазе 1 заменил пилотный _pilots.js. SIMS/THEORY остаются источниками данных
|
||||
* (SIMS → БД в Фазе 4, THEORY сворачивается в манифесты позже).
|
||||
*/
|
||||
(function () {
|
||||
if (!window.LabRegistry) return;
|
||||
if (typeof SIMS === 'undefined') return;
|
||||
var R = window.LabRegistry;
|
||||
var T = (typeof THEORY !== 'undefined') ? THEORY : {};
|
||||
|
||||
// id -> open(ctx). ctx.arg — параметр deep-link (после двоеточия): stereo:cube и т.п.
|
||||
var OPEN = {
|
||||
graph: function (c) { _openGraph(); },
|
||||
projectile: function (c) { _openProjectile(); },
|
||||
collision: function (c) { _openCollision(); },
|
||||
triangle: function (c) { _openTriangle(); },
|
||||
trigcircle: function (c) { _openTrigCircle(); },
|
||||
emfield: function (c) { _openEMField(c.arg || 'E'); },
|
||||
molphys: function (c) { _openMolPhys(c.arg); },
|
||||
circuit: function (c) { _openCircuit(); },
|
||||
chemistry: function (c) { _openChemistry(c.arg); },
|
||||
dynamics: function (c) { _openDynamics(c.arg); },
|
||||
crystal: function (c) { _openCrystal(); },
|
||||
orbitals: function (c) { _openOrbitals(); },
|
||||
stereo: function (c) { _openStereo(c.arg); },
|
||||
chemsandbox: function (c) { _openChemSandbox(); },
|
||||
celldivision: function (c) { _openCellDivision(); },
|
||||
photosynthesis: function (c) { _openPhotosynthesis(); },
|
||||
angrybirds: function (c) { _openAngryBirds(); },
|
||||
quadratic: function (c) { _openQuadratic(); },
|
||||
normaldist: function (c) { _openNormalDist(); },
|
||||
graphtransform: function (c) { _openGraphTransform(); },
|
||||
pendulum: function (c) { _openPendulum(); },
|
||||
equilibrium: function (c) { _openEquilibrium(); },
|
||||
opticsbench: function (c) { _openOpticsBench(c.arg || 'lens'); },
|
||||
isoprocess: function (c) { _openIsoprocess(); },
|
||||
titration: function (c) { _openTitration(); },
|
||||
probability: function (c) { _openProbability(); },
|
||||
bohratom: function (c) { _openBohrAtom(); },
|
||||
electrolysis: function (c) { _openElectrolysis(); },
|
||||
race: function (c) { _openRace(); },
|
||||
waves: function (c) { _openWaves(); },
|
||||
hydrostatics: function (c) { _openHydro(c.arg); },
|
||||
radioactive: function (c) { _openRadioactive(); },
|
||||
geometry: function (c) { _openGeometry(); },
|
||||
logic: function (c) { _openLogic(); },
|
||||
heatengine: function (c) { _openHeatEngine(); },
|
||||
stoichiometry: function (c) { _openStoich(); },
|
||||
qualanalysis: function (c) { _openQualAnalysis(); },
|
||||
periodic: function (c) { _openPeriodic(); },
|
||||
organic: function (c) { _openOrganic(); },
|
||||
solutions: function (c) { _openSolutions(); }
|
||||
};
|
||||
|
||||
SIMS.forEach(function (s) {
|
||||
if (!s.id) return; // "Скоро" — карточка без id
|
||||
var open = OPEN[s.id];
|
||||
if (!open) { // подстраховка: незамапленный id оставляем legacy-пути
|
||||
if (window.console) console.warn('[LabRegistry] нет open() для', s.id);
|
||||
return;
|
||||
}
|
||||
R.register({
|
||||
id: s.id,
|
||||
cat: s.cat,
|
||||
title: s.title,
|
||||
desc: s.desc,
|
||||
preview: s.preview, // уже готовая SVG-строка (P_* вычислены в SIMS)
|
||||
theory: T[s.id] || null,
|
||||
// Фаза 3: ленивая загрузка кода. LabLoader.ensure(id) подгружает файлы
|
||||
// симуляции (+ three.js при необходимости), затем выполняется raw-open.
|
||||
// Если LabLoader недоступен — открываем синхронно как раньше (фолбэк).
|
||||
open: (function (rawOpen, simId) {
|
||||
return function (c) {
|
||||
if (window.LabLoader && window.LabLoader.ensure) {
|
||||
return window.LabLoader.ensure(simId).then(function () { rawOpen(c); });
|
||||
}
|
||||
rawOpen(c);
|
||||
};
|
||||
})(open, s.id)
|
||||
// stop/destroy: глобальный «дробовик» _pauseAllSims()/closeSim() — паритет
|
||||
});
|
||||
});
|
||||
|
||||
// Алиасы deep-link → канонический id[:arg]. Диспетчер openSim() нормализует их
|
||||
// перед обращением к реестру (карточек у алиасов нет — только прямые ссылки).
|
||||
window.LAB_SIM_ALIASES = {
|
||||
magnetic: 'emfield:B',
|
||||
coulomb: 'emfield:E',
|
||||
thinlens: 'opticsbench:lens',
|
||||
mirrors: 'opticsbench:mirror',
|
||||
refraction: 'opticsbench:refraction'
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
/*
|
||||
* LabRegistry — единый реестр симуляций лаборатории (контент-движок).
|
||||
*
|
||||
* Цель: симуляции описываются декларативным манифестом и сами себя регистрируют,
|
||||
* вместо захардкоженных массивов (SIMS), if-цепочек (openSim) и объектов (THEORY).
|
||||
*
|
||||
* Манифест:
|
||||
* {
|
||||
* id: 'pendulum', // уникальный, без ':arg'
|
||||
* cat: 'phys', // math | phys | chem | bio | game
|
||||
* title: 'Маятник',
|
||||
* desc: 'Колебания, период…',
|
||||
* preview: string | function(), // SVG-разметка карточки (функция вычисляется лениво)
|
||||
* theory: { title, sections[] },// объект для панели теории (как в THEORY)
|
||||
* bodyId: 'sim-pendulum', // (опц.) id тела; mount() — для ленивого создания DOM (Фаза 2)
|
||||
* mount: function(host){}, // (опц.) ленивое монтирование тела
|
||||
* open: function(ctx){}, // ctx = { id, arg } — открыть/инициализировать
|
||||
* stop: function(){}, // (опц.) остановить анимации (не разрушая)
|
||||
* destroy: function(){}, // (опц.) полностью закрыть; по умолчанию == stop
|
||||
* subject, grade, topics // (опц.) курикулумные поля (Фаза 5)
|
||||
* }
|
||||
*
|
||||
* Загружается ПЕРВЫМ среди labs-скриптов, чтобы window.LabRegistry существовал
|
||||
* к моменту исполнения тел остальных модулей.
|
||||
*/
|
||||
(function () {
|
||||
var _list = []; // манифесты в порядке регистрации
|
||||
var _byId = {}; // id -> манифест
|
||||
var _active = null; // текущая открытая симуляция
|
||||
|
||||
function _baseId(id) {
|
||||
return id == null ? id : String(id).split(':')[0];
|
||||
}
|
||||
|
||||
function register(m) {
|
||||
if (!m || !m.id) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(_byId, m.id)) {
|
||||
// перерегистрация: заменить на месте, сохранив позицию
|
||||
for (var i = 0; i < _list.length; i++) {
|
||||
if (_list[i].id === m.id) { _list[i] = m; break; }
|
||||
}
|
||||
} else {
|
||||
_list.push(m);
|
||||
}
|
||||
_byId[m.id] = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
function get(id) {
|
||||
var b = _baseId(id);
|
||||
return Object.prototype.hasOwnProperty.call(_byId, b) ? _byId[b] : null;
|
||||
}
|
||||
|
||||
function has(id) { return !!get(id); }
|
||||
|
||||
function all() { return _list.slice(); }
|
||||
|
||||
function setActive(m) { _active = m || null; }
|
||||
|
||||
function stopActive() {
|
||||
if (_active && typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
function destroyActive() {
|
||||
if (_active) {
|
||||
if (typeof _active.destroy === 'function') {
|
||||
try { _active.destroy(); } catch (e) { /* noop */ }
|
||||
} else if (typeof _active.stop === 'function') {
|
||||
try { _active.stop(); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
|
||||
function active() { return _active; }
|
||||
|
||||
// Разрешить preview (строка или функция) в готовую разметку.
|
||||
function resolvePreview(m) {
|
||||
if (!m) return '';
|
||||
var p = m.preview;
|
||||
if (typeof p === 'function') {
|
||||
try { return p() || ''; } catch (e) { return ''; }
|
||||
}
|
||||
return p || '';
|
||||
}
|
||||
|
||||
window.LabRegistry = {
|
||||
register: register,
|
||||
get: get,
|
||||
has: has,
|
||||
all: all,
|
||||
setActive: setActive,
|
||||
stopActive: stopActive,
|
||||
destroyActive: destroyActive,
|
||||
active: active,
|
||||
resolvePreview: resolvePreview
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,300 @@
|
||||
'use strict';
|
||||
/* Контент-движок, Фаза 3 — манифест зависимостей симуляций (СГЕНЕРИРОВАН).
|
||||
id -> { open: имя глобальной _openX, files: [ленивые файлы], three: нужен ли three.js }.
|
||||
Файлы загружаются лениво по клику (см. _loader.js). three.js — только для 3D-симуляций.
|
||||
Самовосстановление в _loader: если после загрузки open-функция не определена,
|
||||
грузятся ВСЕ ленивые файлы -> корректность не зависит от точности манифеста.
|
||||
Регенерация: node tools/gen-sim-deps.js (см. CONTEXT). НЕ редактировать вручную. */
|
||||
window.SIM_DEPS = {
|
||||
"graph": {
|
||||
"open": "_openGraph",
|
||||
"files": [],
|
||||
"three": false
|
||||
},
|
||||
"projectile": {
|
||||
"open": "_openProjectile",
|
||||
"files": [
|
||||
"projectile.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"collision": {
|
||||
"open": "_openCollision",
|
||||
"files": [
|
||||
"collision.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"triangle": {
|
||||
"open": "_openTriangle",
|
||||
"files": [
|
||||
"triangle.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"trigcircle": {
|
||||
"open": "_openTrigCircle",
|
||||
"files": [
|
||||
"trigcircle.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"emfield": {
|
||||
"open": "_openEMField",
|
||||
"files": [
|
||||
"emfield.js",
|
||||
"logic.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"molphys": {
|
||||
"open": "_openMolPhys",
|
||||
"files": [
|
||||
"brownian.js",
|
||||
"diffusion.js",
|
||||
"gas.js",
|
||||
"states.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"circuit": {
|
||||
"open": "_openCircuit",
|
||||
"files": [
|
||||
"circuit.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"chemistry": {
|
||||
"open": "_openChemistry",
|
||||
"files": [
|
||||
"circuit.js",
|
||||
"flask.js",
|
||||
"ionexchange.js",
|
||||
"reactions.js",
|
||||
"redox.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"dynamics": {
|
||||
"open": "_openDynamics",
|
||||
"files": [
|
||||
"forcesandbox.js",
|
||||
"newton.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"crystal": {
|
||||
"open": "_openCrystal",
|
||||
"files": [
|
||||
"crystal.js"
|
||||
],
|
||||
"three": true
|
||||
},
|
||||
"orbitals": {
|
||||
"open": "_openOrbitals",
|
||||
"files": [
|
||||
"orbitals.js"
|
||||
],
|
||||
"three": true
|
||||
},
|
||||
"stereo": {
|
||||
"open": "_openStereo",
|
||||
"files": [
|
||||
"stereo.js"
|
||||
],
|
||||
"three": true
|
||||
},
|
||||
"chemsandbox": {
|
||||
"open": "_openChemSandbox",
|
||||
"files": [
|
||||
"chemsandbox.js",
|
||||
"collision.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"celldivision": {
|
||||
"open": "_openCellDivision",
|
||||
"files": [
|
||||
"celldivision.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"photosynthesis": {
|
||||
"open": "_openPhotosynthesis",
|
||||
"files": [
|
||||
"photosynthesis.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"angrybirds": {
|
||||
"open": "_openAngryBirds",
|
||||
"files": [
|
||||
"angrybirds.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"quadratic": {
|
||||
"open": "_openQuadratic",
|
||||
"files": [
|
||||
"quadratic.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"normaldist": {
|
||||
"open": "_openNormalDist",
|
||||
"files": [
|
||||
"normaldist.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"graphtransform": {
|
||||
"open": "_openGraphTransform",
|
||||
"files": [
|
||||
"graphtransform.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"pendulum": {
|
||||
"open": "_openPendulum",
|
||||
"files": [
|
||||
"pendulum.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"equilibrium": {
|
||||
"open": "_openEquilibrium",
|
||||
"files": [
|
||||
"equilibrium.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"opticsbench": {
|
||||
"open": "_openOpticsBench",
|
||||
"files": [
|
||||
"opticsbench.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"isoprocess": {
|
||||
"open": "_openIsoprocess",
|
||||
"files": [
|
||||
"isoprocess.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"titration": {
|
||||
"open": "_openTitration",
|
||||
"files": [
|
||||
"titration.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"probability": {
|
||||
"open": "_openProbability",
|
||||
"files": [
|
||||
"probability.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"bohratom": {
|
||||
"open": "_openBohrAtom",
|
||||
"files": [
|
||||
"bohratom.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"electrolysis": {
|
||||
"open": "_openElectrolysis",
|
||||
"files": [
|
||||
"electrolysis.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"race": {
|
||||
"open": "_openRace",
|
||||
"files": [
|
||||
"race.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"waves": {
|
||||
"open": "_openWaves",
|
||||
"files": [
|
||||
"waves.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"hydrostatics": {
|
||||
"open": "_openHydro",
|
||||
"files": [
|
||||
"hydrostatics.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"radioactive": {
|
||||
"open": "_openRadioactive",
|
||||
"files": [
|
||||
"radioactive.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"geometry": {
|
||||
"open": "_openGeometry",
|
||||
"files": [
|
||||
"geometry.js",
|
||||
"triangle.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"logic": {
|
||||
"open": "_openLogic",
|
||||
"files": [
|
||||
"logic.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"heatengine": {
|
||||
"open": "_openHeatEngine",
|
||||
"files": [
|
||||
"heatengine.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"stoichiometry": {
|
||||
"open": "_openStoich",
|
||||
"files": [
|
||||
"stoichiometry.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"qualanalysis": {
|
||||
"open": "_openQualAnalysis",
|
||||
"files": [
|
||||
"qualanalysis.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"periodic": {
|
||||
"open": "_openPeriodic",
|
||||
"files": [
|
||||
"_periodic_data.js",
|
||||
"periodic.js"
|
||||
],
|
||||
"three": true
|
||||
},
|
||||
"organic": {
|
||||
"open": "_openOrganic",
|
||||
"files": [
|
||||
"organic.js"
|
||||
],
|
||||
"three": false
|
||||
},
|
||||
"solutions": {
|
||||
"open": "_openSolutions",
|
||||
"files": [
|
||||
"solutions.js"
|
||||
],
|
||||
"three": false
|
||||
}
|
||||
};
|
||||
window.LAB_LAZY_FILES = ["angrybirds.js","bohratom.js","brownian.js","celldivision.js","chemsandbox.js","circuit.js","collision.js","crystal.js","diffusion.js","electrolysis.js","emfield.js","equilibrium.js","flask.js","forcesandbox.js","gas.js","geometry.js","graphtransform.js","heatengine.js","hydrostatics.js","ionexchange.js","isoprocess.js","logic.js","newton.js","normaldist.js","opticsbench.js","orbitals.js","organic.js","pendulum.js","periodic.js","photosynthesis.js","probability.js","projectile.js","quadratic.js","qualanalysis.js","race.js","radioactive.js","reactions.js","redox.js","solutions.js","states.js","stereo.js","stoichiometry.js","titration.js","triangle.js","trigcircle.js","waves.js","_periodic_data.js"];
|
||||
@@ -20,11 +20,25 @@
|
||||
}
|
||||
|
||||
function renderSims() {
|
||||
const base = _catFilter === 'all' ? SIMS : SIMS.filter(s => s.cat === _catFilter);
|
||||
// Контент-движок: мёрж код-реестра поверх legacy SIMS.
|
||||
// Порядок берём из SIMS; для мигрированных id используем манифест реестра;
|
||||
// registry-only записи добавляем в конец.
|
||||
const _reg = (window.LabRegistry ? window.LabRegistry.all() : []);
|
||||
const _regById = {};
|
||||
_reg.forEach(m => { _regById[m.id] = m; });
|
||||
const _seen = {};
|
||||
const _merged = [];
|
||||
SIMS.forEach(s => {
|
||||
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
||||
if (s.id) _seen[s.id] = 1;
|
||||
});
|
||||
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
|
||||
|
||||
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
||||
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
||||
document.getElementById('sim-grid').innerHTML = list.map(s => `
|
||||
<div class="sim-card ${s.id ? '' : 'soon'}" ${s.id ? `onclick="openSim('${s.id}')"` : ''}>
|
||||
${s.preview}
|
||||
${window.LabRegistry ? window.LabRegistry.resolvePreview(s) : s.preview}
|
||||
<div class="sim-body">
|
||||
<div class="sim-cat ${s.cat}">${s.cat === 'math' ? '∑ Математика' : s.cat === 'chem' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия' : s.cat === 'bio' ? '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg> Биология' : s.cat === 'game' ? '<svg class="ic" viewBox="0 0 24 24"><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="15" y1="13" x2="15.01" y2="13"/><line x1="18" y1="11" x2="18.01" y2="11"/><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры' : LS.icon('zap',14) + ' Физика'}</div>
|
||||
<div class="sim-title">${s.title}</div>
|
||||
@@ -935,7 +949,9 @@
|
||||
}
|
||||
|
||||
function loadTheory(simId) {
|
||||
const t = THEORY[simId];
|
||||
// Контент-движок: теория мигрированных симуляций берётся из манифеста реестра.
|
||||
const _rm = window.LabRegistry ? window.LabRegistry.get(simId) : null;
|
||||
const t = (_rm && _rm.theory) ? _rm.theory : THEORY[simId];
|
||||
const el = document.getElementById('theory-content');
|
||||
if (!t) { el.innerHTML = '<div class="tp-text" style="text-align:center;padding:40px 0;color:var(--text-3)">Теория для этой симуляции пока не добавлена</div>'; return; }
|
||||
let html = `<div class="tp-title">${LS.icon('book-open',16)} ${t.title}</div>`;
|
||||
@@ -955,6 +971,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Контент-движок, Фаза 5: чип «Связано с программой» ──────────────────
|
||||
Подтягивает курикулумные связи симуляции (GET /api/lab/sims/:id/related) и
|
||||
рендерит чипы-ссылки рядом с заголовком симуляции. Самодостаточно: создаёт
|
||||
контейнер #sim-related динамически (без правок lab.html/CSS — меньше риск
|
||||
конфликта с параллельными сессиями). Тихо прячется, если связей нет/ошибка. */
|
||||
var _LAB_LINK_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
function _labRelEsc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function _ensureRelatedHost() {
|
||||
var host = document.getElementById('sim-related');
|
||||
if (host) return host;
|
||||
host = document.createElement('div');
|
||||
host.id = 'sim-related';
|
||||
host.style.cssText = 'display:none;align-items:center;gap:6px;flex-wrap:wrap;margin-left:14px;min-width:0';
|
||||
var title = document.getElementById('sim-topbar-title');
|
||||
if (title && title.parentNode) title.parentNode.insertBefore(host, title.nextSibling);
|
||||
return host;
|
||||
}
|
||||
function _loadRelated(simId) {
|
||||
var host = _ensureRelatedHost();
|
||||
host.style.display = 'none';
|
||||
host.innerHTML = '';
|
||||
if (!window.LS || !LS.api) return;
|
||||
LS.api('/api/lab/sims/' + encodeURIComponent(simId) + '/related')
|
||||
.then(function (data) {
|
||||
var links = (data && data.links) || {};
|
||||
var all = [].concat(links.textbook || [], links.topic || [], links.kmap || [], links.question || []);
|
||||
if (!all.length) return;
|
||||
var chipBase = 'display:inline-flex;align-items:center;gap:4px;font-size:.72rem;padding:3px 9px;border-radius:999px;';
|
||||
var html = '<span style="font-size:.68rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em">'
|
||||
+ _LAB_LINK_ICON + ' Связано с программой</span>';
|
||||
all.forEach(function (l) {
|
||||
var label = _labRelEsc(l.label || (l.kind + ':' + l.ref_id));
|
||||
if (l.href) {
|
||||
html += '<a href="' + _labRelEsc(l.href) + '" title="Открыть в учебнике" style="' + chipBase
|
||||
+ 'background:rgba(155,93,229,.14);color:var(--violet);text-decoration:none;border:1px solid rgba(155,93,229,.32)">' + label + '</a>';
|
||||
} else {
|
||||
html += '<span style="' + chipBase
|
||||
+ 'background:rgba(255,255,255,.06);color:var(--text-2);border:1px solid rgba(255,255,255,.12)">' + label + '</span>';
|
||||
}
|
||||
});
|
||||
host.innerHTML = html;
|
||||
host.style.display = 'flex';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
})
|
||||
.catch(function () { /* нет связей или ошибка — чип просто не показываем */ });
|
||||
}
|
||||
window._loadRelated = _loadRelated;
|
||||
|
||||
/* ── embed mode + auto-open from ?sim= ── */
|
||||
const _qp = new URLSearchParams(location.search);
|
||||
var _embedMode = _qp.get('embed') === '1';
|
||||
|
||||
@@ -30,6 +30,19 @@
|
||||
var geomSim = null;
|
||||
var qualSim = null;
|
||||
|
||||
/* Контент-движок, Фаза 3 (ленивая загрузка): часть глобалов с экземплярами
|
||||
симуляций объявляется внутри их собственных НЫНЕ ЛЕНИВЫХ файлов, поэтому до
|
||||
первого открытия такой симуляции они не существуют. Legacy-«дробовик»
|
||||
_pauseAllSims()/closeSim() ссылается на них по голому имени, что до загрузки
|
||||
любого файла бросало ReferenceError (напр. cirSim). Предсоздаём эти имена как
|
||||
свойства window (null), чтобы guard'ы безопасно давали false; при загрузке
|
||||
файла симуляции его собственный var/присваивание обновит тот же глобал. */
|
||||
['cirSim','reacSim','flaskSim','newtonSim','sandboxSim','crystalSim','orbitalsSim',
|
||||
'stereoSim','angryBirdsSim','trigSim','pendSim','radioactiveSim','heSim',
|
||||
'periodicSim','organicSim','_solutionsSim','mirrorSim'].forEach(function (_n) {
|
||||
if (!(_n in window)) window[_n] = null;
|
||||
});
|
||||
|
||||
var ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-emfield',
|
||||
'sim-molphys',
|
||||
'sim-circuit','sim-chemistry','sim-dynamics',
|
||||
@@ -52,6 +65,7 @@
|
||||
// Pause all animation-loop sims (non-destructive). Called when switching
|
||||
// between sims so a previously opened sim doesn't keep rendering offscreen.
|
||||
function _pauseAllSims() {
|
||||
if (window.LabRegistry) window.LabRegistry.stopActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (gasSim) gasSim.stop();
|
||||
@@ -105,58 +119,34 @@
|
||||
// load theory for this sim
|
||||
loadTheory(id.includes(':') ? id.split(':')[0] : id);
|
||||
|
||||
if (id === 'graph') _openGraph();
|
||||
if (id === 'projectile') _openProjectile();
|
||||
if (id === 'collision') _openCollision();
|
||||
if (id === 'triangle') _openTriangle();
|
||||
if (id === 'trigcircle') _openTrigCircle();
|
||||
if (id === 'magnetic') _openEMField('B'); // backward compat: #magnetic → emfield B-mode
|
||||
if (id === 'coulomb') _openEMField('E'); // backward compat: #coulomb → emfield E-mode
|
||||
if (id === 'emfield') _openEMField('E');
|
||||
if (id.startsWith('emfield:')) { _openEMField(id.split(':')[1]); }
|
||||
if (id === 'molphys') _openMolPhys();
|
||||
if (id.startsWith('molphys:')) { _openMolPhys(id.split(':')[1]); }
|
||||
if (id === 'circuit') _openCircuit();
|
||||
if (id === 'chemistry') _openChemistry();
|
||||
if (id.startsWith('chemistry:')) { _openChemistry(id.split(':')[1]); }
|
||||
if (id === 'dynamics') _openDynamics();
|
||||
if (id.startsWith('dynamics:')) { _openDynamics(id.split(':')[1]); }
|
||||
if (id === 'crystal') _openCrystal();
|
||||
if (id === 'orbitals') _openOrbitals();
|
||||
if (id === 'stereo') _openStereo();
|
||||
if (id.startsWith('stereo:')) { _openStereo(id.split(':')[1]); }
|
||||
if (id === 'chemsandbox') _openChemSandbox();
|
||||
if (id === 'celldivision') _openCellDivision();
|
||||
if (id === 'photosynthesis') _openPhotosynthesis();
|
||||
if (id === 'angrybirds') _openAngryBirds();
|
||||
if (id === 'quadratic') _openQuadratic();
|
||||
if (id === 'normaldist') _openNormalDist();
|
||||
if (id === 'graphtransform') _openGraphTransform();
|
||||
if (id === 'pendulum') _openPendulum();
|
||||
if (id === 'equilibrium') _openEquilibrium();
|
||||
if (id === 'opticsbench') _openOpticsBench('lens');
|
||||
if (id.startsWith('opticsbench:')) _openOpticsBench(id.split(':')[1]);
|
||||
if (id === 'thinlens') _openOpticsBench('lens'); // backward compat
|
||||
if (id === 'mirrors') _openOpticsBench('mirror'); // backward compat
|
||||
if (id === 'refraction') _openOpticsBench('refraction'); // backward compat
|
||||
if (id === 'isoprocess') _openIsoprocess();
|
||||
if (id === 'titration') _openTitration();
|
||||
if (id === 'probability') _openProbability();
|
||||
if (id === 'bohratom') _openBohrAtom();
|
||||
if (id === 'electrolysis') _openElectrolysis();
|
||||
if (id === 'race') _openRace();
|
||||
if (id === 'waves') _openWaves();
|
||||
if (id === 'hydrostatics') _openHydro();
|
||||
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
|
||||
if (id === 'radioactive') _openRadioactive();
|
||||
if (id === 'geometry') _openGeometry();
|
||||
if (id === 'logic') _openLogic();
|
||||
if (id === 'heatengine') _openHeatEngine();
|
||||
if (id === 'stoichiometry') _openStoich();
|
||||
if (id === 'qualanalysis') _openQualAnalysis();
|
||||
if (id === 'periodic') _openPeriodic();
|
||||
if (id === 'organic') _openOrganic();
|
||||
if (id === 'solutions') _openSolutions();
|
||||
// Фаза 5: чип «Связано с программой» (курикулумные связи симуляции).
|
||||
if (typeof _loadRelated === 'function') _loadRelated(id.includes(':') ? id.split(':')[0] : id);
|
||||
|
||||
// ── Контент-движок (Фаза 1): диспетчеризация через реестр ──
|
||||
// Все каталожные симуляции зарегистрированы в _register-all.js.
|
||||
// Алиасы deep-link (magnetic/coulomb/thinlens/mirrors/refraction) нормализуем
|
||||
// в канонический id[:arg] перед обращением к реестру.
|
||||
var _aliases = window.LAB_SIM_ALIASES || {};
|
||||
var _cid = _aliases[id.split(':')[0]] || id;
|
||||
if (window.LabRegistry && window.LabRegistry.has(_cid)) {
|
||||
const _m = window.LabRegistry.get(_cid);
|
||||
const _arg = _cid.includes(':') ? _cid.split(':')[1] : undefined;
|
||||
window.LabRegistry.setActive(_m);
|
||||
// Фаза 3: open() может вернуть Promise (ленивая загрузка кода). Иконки
|
||||
// перерисовываем после фактической инициализации тела симуляции; ошибку
|
||||
// асинхронной загрузки ловим через .catch (sync try/catch её не поймает).
|
||||
try {
|
||||
const _r = _m.open({ id: _cid, arg: _arg });
|
||||
if (_r && typeof _r.then === 'function') {
|
||||
_r.then(function () { if (window.lucide) lucide.createIcons(); })
|
||||
.catch(function (e) { console.error('[LabRegistry] open failed:', _cid, e); });
|
||||
} else if (window.lucide) {
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (e) { console.error('[LabRegistry] open failed:', _cid, e); }
|
||||
return;
|
||||
}
|
||||
if (window.console) console.warn('[LabRegistry] неизвестная симуляция:', id);
|
||||
}
|
||||
|
||||
function _simShow(elId) {
|
||||
@@ -210,6 +200,7 @@
|
||||
}
|
||||
|
||||
function closeSim() {
|
||||
if (window.LabRegistry) window.LabRegistry.destroyActive();
|
||||
if (pSim) pSim.pause();
|
||||
if (cSim) cSim.pause();
|
||||
if (mSim && mSim.particleOn) mSim.toggleParticle();
|
||||
|
||||
Reference in New Issue
Block a user