feat(chemistry-8): U3 — genetic-карта классов (§22) + анимация растворения (§47)

chem8_svg.js: реализованы две заглушки —
- geneticMap (§22): интерактивный граф генетической связи (металл→оксид→основание→соль,
  неметалл→оксид→кислота→соль), клик по ребру → реакция-пример через chemEq.
- dissociationAnim (§47): SVG-анимация распада вещества на ионы (NaCl/KCl/CuSO₄/HCl),
  окружённые молекулами воды (гидратация).

Подключены: §22 (Гл.1) и §47 (Гл.6, заменил статичную анимацию). CSS gm/ds.
redoxBalancer §44 — остаётся пошаговым преднабором (ch5). orbitalDiagram §33 — покрыт atomShell.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-05-30 16:21:01 +03:00
parent 9ebd86e220
commit 72bd3ff72c
8 changed files with 127 additions and 11 deletions
+12
View File
@@ -82,6 +82,12 @@ test('ch1: тренажёр задач отрисован для §10', async ()
assert.ok(doc.querySelectorAll('#navDotsp10 .nav-dot').length >= 4, 'навигация по задачам §10');
});
test('ch1: генетическая карта §22 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('p22'); await wait(120);
assert.ok(doc.querySelectorAll('#c-genetic .gm-edge').length >= 6, 'граф классов §22');
});
/* ── Глава 2 ── */
test('ch2: SPA без ошибок, 6 карточек, §24 активен, ПСХЭ', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js');
@@ -203,3 +209,9 @@ test('ch6: SPA без ошибок, 8 карточек, §46 активен, w/c
doc.defaultView.goTo('p51'); await wait(120);
assert.ok(doc.querySelector('#c-ccalc #c-go'), 'калькулятор c §51');
});
test('ch6: анимация растворения §47 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch6.html', '/js/chem8_ch6_widgets.js');
doc.defaultView.goTo('p47'); await wait(120);
assert.ok(doc.querySelector('#c-dissoc .ds-svg'), 'анимация диссоциации §47');
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('Chem8.elementCounts — скобки и индексы', () => {
});
test('Chem8 — оставшиеся заглушки возвращают null и не падают', () => {
for (const fn of ['redoxBalancer', 'orbitalDiagram', 'dissociationAnim', 'geneticMap']) {
for (const fn of ['redoxBalancer', 'orbitalDiagram']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
assert.equal(C[fn]({}), null, fn + ' заглушка возвращает null');
}
+10
View File
@@ -401,6 +401,16 @@ html.dark .lat-card h4{color:var(--pri-l)}
.amph-stage{display:flex;justify-content:center;margin:8px 0}
.amph-out{margin-top:6px}
/* геном-карта классов (§22) */
.gm-svg{width:100%;max-width:440px;height:auto;color:var(--text);display:block;margin:4px auto}
.gm-out{margin-top:8px}
.gm-edge{transition:stroke .15s,stroke-width .15s}
/* диссоциация/растворение (§47) */
.ds-svg{width:100%;max-width:300px;height:auto;display:block;margin:6px auto}
.ds-stage{display:flex;justify-content:center}
.ds-out{margin-top:6px}
/* exa-step (разбор примеров) */
.exa-step{font-family:var(--mono);font-size:.9rem;background:var(--card-soft);border-left:3px solid var(--pri);border-radius:0 8px 8px 0;padding:8px 12px;margin:6px 0}
+4 -1
View File
@@ -98,6 +98,9 @@
render();
}
/* §22 — генетическая карта классов */
function mount_p22() { var el = $('c-genetic'); if (el && !el._b && C().geneticMap) { el._b = 1; C().geneticMap(el, {}); } }
W.CHEM8_WIDGETS = { p13: mount_p13, p16: mount_p16, p17: mount_p17, p18: mount_p18 };
W.FLAG_MOUNTS = { p10: mount_p10, p14: mount_p14, p19: mount_p19, p20: mount_p20, p23: mount_p23 };
W.FLAG_MOUNTS = { p10: mount_p10, p14: mount_p14, p19: mount_p19, p20: mount_p20, p22: mount_p22, p23: mount_p23 };
})(window);
+4 -1
View File
@@ -76,6 +76,9 @@
$('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' }); } }
W.CHEM8_WIDGETS = { p46: mount_p46, p50: mount_p50, p51: mount_p51 };
W.FLAG_MOUNTS = { p48: mount_p48 };
W.FLAG_MOUNTS = { p47: mount_p47, p48: mount_p48 };
})(window);
+94 -5
View File
@@ -800,6 +800,94 @@
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 };
}
/* ──────────────────────────────────────────────────────────────────────────
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 () {
@@ -844,11 +932,12 @@
// готово (Phase 6 — ОВР)
oxStateCalc: oxStateCalc, // §42 — калькулятор степени окисления
oxStates: oxStates, // степени окисления (чистая функция)
// заглушки (см. план, разд. B)наполняются позже
redoxBalancer: notImplemented('redoxBalancer'), // §44e-баланс ОВР (пошагово в ch5)
orbitalDiagram: notImplemented('orbitalDiagram'), // §33орбитальная диаграмма
dissociationAnim: notImplemented('dissociationAnim'),// §47 — анимация растворения
geneticMap: notImplemented('geneticMap') // §22 — генетическая карта-граф классов
// готово (Phase 8/U3 — апгрейд)
geneticMap: geneticMap, // §22генетическая карта-граф классов
dissociationAnim: dissociationAnim, // §47анимация растворения/диссоциации
// редокс-баланс §44 реализован пошагово в chem8_ch5_widgets (преднабор)
redoxBalancer: notImplemented('redoxBalancer'),
orbitalDiagram: notImplemented('orbitalDiagram') // §33 — покрыто atomShell
};
global.Chem8 = Chem8;
+1
View File
@@ -328,6 +328,7 @@ function bp21(){ document.getElementById('p21-body').innerHTML =
function bp22(){ document.getElementById('p22-body').innerHTML =
hero(4,'§ 22 · Глава 1','Взаимосвязь классов · ПР 3','генетическая связь','Все классы связаны цепочками превращений — от простого вещества до соли.',['генетика','ПР.3'])
+makeCard('theory','Генетическая связь','§22','<p><b>Ряд металла:</b> металл → основный оксид → основание → соль<br>Na → Na₂O → NaOH → NaCl</p><p><b>Ряд неметалла:</b> неметалл → кислотный оксид → кислота → соль<br>S → SO₃ → H₂SO₄ → Na₂SO₄</p><p>Эти ряды «встречаются» в солях — продукте реакции кислоты и основания.</p>')
+flag('Генетическая карта классов','Кликни по стрелке-переходу — увидишь реакцию-пример. Два ряда (металл и неметалл) сходятся в соли.','<div id="c-genetic"></div>')
+'<div class="insight-box"><div class="insight-title">Цепочка превращений</div><p>Ca → CaO → Ca(OH)₂ → CaCl₂. Попробуй записать уравнение каждого перехода!</p></div>'
+makeCard('lab','Практическая работа 3 · Экспериментальные задачи',null,'<p>По выданным реактивам осуществи цепочку превращений и докажи получение каждого вещества (например, получи из меди — оксид меди, затем соль).</p>')
+rememberBox(['Металл и неметалл — начала двух генетических рядов.','Соль — точка встречи кислотного и основного «миров».'])
+1 -3
View File
@@ -174,9 +174,7 @@ function bp46(){ document.getElementById('p46-body').innerHTML =
function bp47(){ document.getElementById('p47-body').innerHTML =
hero(4,'§ 47 · Глава 6','Растворение веществ в воде','растворитель + в-во','Что происходит, когда сахар «исчезает» в чае: вода разбирает вещество на частицы.',['раствор','гидратация'])
+makeCard('theory','Как идёт растворение','§47','<p><b>Раствор</b> = растворитель (чаще вода) + растворённое вещество. При растворении молекулы воды окружают частицы вещества и «растаскивают» их — это <b>гидратация</b>. При этом тепло может выделяться (растворение щёлочи) или поглощаться (растворение соли).</p>')
+'<div class="wgt"><div class="wgt-h"><svg class="ic" viewBox="0 0 24 24"><path d="M12 2v6M12 22a7 7 0 0 0 7-7c0-4-7-13-7-13S5 11 5 15a7 7 0 0 0 7 7z"/></svg> Растворение частиц вещества</div><div class="bt-stage"><svg viewBox="0 0 220 90" style="width:100%;max-width:300px;color:var(--pri)"><rect x="10" y="10" width="200" height="70" rx="10" fill="var(--pri)" opacity=".08" stroke="var(--pri)" stroke-width="1.5"/>'
+[ '40,30','70,55','110,35','150,60','185,30' ].map(function(p,i){var xy=p.split(',');return '<circle cx="'+xy[0]+'" cy="'+xy[1]+'" r="6" fill="var(--pri)"><animate attributeName="cy" values="'+xy[1]+';'+(+xy[1]-6)+';'+xy[1]+'" dur="'+(2+i*0.3)+'s" repeatCount="indefinite"/></circle>';}).join('')
+'</svg></div><div class="out"><span class="bd">Частицы вещества (точки) равномерно распределяются среди молекул воды.</span></div></div>'
+flag('Анимация растворения и гидратации','Выбери вещество — увидишь, как оно распадается на ионы, окружённые молекулами воды.','<div id="c-dissoc"></div>')
+rememberBox(['Раствор = растворитель + растворённое вещество.','Гидратация — окружение частиц молекулами воды.'])
+qList(['Из чего состоит любой раствор?','Что такое гидратация?'])
+secNav('p46','p48')+readButton('p47'); wireReadBtn('p47'); }