diff --git a/backend/tests/chemistry8-page.test.js b/backend/tests/chemistry8-page.test.js index 0f96f32..2d7ab7f 100644 --- a/backend/tests/chemistry8-page.test.js +++ b/backend/tests/chemistry8-page.test.js @@ -19,6 +19,7 @@ function buildPage(file, widgetsSrc) { const inl = { '/js/biochem-core.js': readF('frontend/js/biochem-core.js'), '/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'), + '/js/chem8_mol.js': readF('frontend/js/chem8_mol.js'), [widgetsSrc]: readF('frontend/js' + widgetsSrc.replace('/js', '')), '/js/chem8_engine.js': readF('frontend/js/chem8_engine.js') }; @@ -136,6 +137,18 @@ test('ch4: SPA без ошибок, 7 карточек, §36 активен, т assert.ok(doc.querySelector('#c-bond2 .bt-out'), 'виджет полярности §38'); }); +test('ch4: 3D-модели молекул §38 и решётки §41 монтируются (U4)', async () => { + const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); + doc.defaultView.goTo('p38'); await wait(140); + assert.ok(doc.querySelector('#c-mol .mol-sel'), 'выбор молекулы §38'); + assert.ok(doc.querySelector('#c-mol canvas'), 'canvas 3D-модели §38'); + assert.ok(doc.querySelector('#c-mol .mol-info'), 'инфо о молекуле §38'); + doc.defaultView.goTo('p41'); await wait(140); + assert.ok(doc.querySelector('#c-lattice .lat-sel'), 'выбор решётки §41'); + assert.ok(doc.querySelector('#c-lattice canvas'), 'canvas решётки §41'); +}); + /* ── Глава 5 ── */ test('ch5: SPA без ошибок, 5 карточек, §42 активен, с.о. и баланс', async () => { const { doc, errors } = await loadDom('chemistry_8_ch5.html', '/js/chem8_ch5_widgets.js'); diff --git a/frontend/css/chem8-textbook.css b/frontend/css/chem8-textbook.css index 5d971f9..9fa2a7d 100644 --- a/frontend/css/chem8-textbook.css +++ b/frontend/css/chem8-textbook.css @@ -401,6 +401,11 @@ html.dark .lat-card h4{color:var(--pri-l)} .amph-stage{display:flex;justify-content:center;margin:8px 0} .amph-out{margin-top:6px} +/* 3D-модели молекул/решёток (§38,41) */ +.mol-cv{background:#0b1220;cursor:grab;border:1px solid var(--border)} +.mol-cv:active{cursor:grabbing} +.mol-info{margin-top:8px} + /* геном-карта классов (§22) */ .gm-svg{width:100%;max-width:440px;height:auto;color:var(--text);display:block;margin:4px auto} .gm-out{margin-top:8px} diff --git a/frontend/js/chem8_ch4_widgets.js b/frontend/js/chem8_ch4_widgets.js index 674fb31..013efc4 100644 --- a/frontend/js/chem8_ch4_widgets.js +++ b/frontend/js/chem8_ch4_widgets.js @@ -6,9 +6,14 @@ 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' }); } } + 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'); } } W.CHEM8_WIDGETS = {}; - W.FLAG_MOUNTS = { p37: mount_p37, p38: mount_p38 }; + W.FLAG_MOUNTS = { p37: mount_p37, p38: mount_p38, p41: mount_p41 }; })(window); diff --git a/frontend/js/chem8_mol.js b/frontend/js/chem8_mol.js new file mode 100644 index 0000000..06b842c --- /dev/null +++ b/frontend/js/chem8_mol.js @@ -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 = '
' + + '
'; + 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 = '' + cur.name + ' · M = ' + (C().fmt ? C().fmt(mr) : mr) + ' г/моль
' + + 'Связь: ' + bondTxt + ' · молекула: ' + pol.label.toLowerCase() + '' + + (g.shape ? ' · форма: ' + g.shape : '') + '
'; + } + 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 = '
'; + 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 = '' + l.name + '
' + l.note + '
'; } + 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); diff --git a/frontend/textbooks/chemistry_8_ch4.html b/frontend/textbooks/chemistry_8_ch4.html index 755e4e9..c0c60f4 100644 --- a/frontend/textbooks/chemistry_8_ch4.html +++ b/frontend/textbooks/chemistry_8_ch4.html @@ -23,6 +23,7 @@ html.dark{ --bg:#0a1a12; --card:#10271c; --card-soft:#143524; --text:#d1fae5; -- + @@ -173,6 +174,7 @@ function bp38(){ document.getElementById('p38-body').innerHTML = +makeCard('theory','Электроотрицательность и полярность','§38','
Электроотрицательность (ЭО) — способность атома притягивать к себе общие электроны.

Если ЭО атомов одинакова (H₂, Cl₂) — общая пара поделена поровну, связь неполярная. Если ЭО разная (HCl, H₂O) — пара смещена к более электроотрицательному атому, связь полярная (возникают частичные заряды δ+ и δ−).

') +flag('Конструктор связи: ΔЭО → тип','Меняй атомы — видно, как разница ЭО превращает связь из неполярной в полярную и далее в ионную.','
') +makeCard('lab','Лабораторный опыт 4 · Составление моделей молекул',null,'

Соберите шаростержневые модели молекул H₂, Cl₂, HCl, H₂O, CO₂. Определите для каждой тип связи (полярная/неполярная) и форму молекулы. Сравните: в симметричных молекулах (CO₂) полярные связи «компенсируются», и молекула в целом неполярна.

') + +flag('3D-модели молекул','Выбери молекулу и вращай её мышью. Снизу — молярная масса, тип связи, форма и полярность.','
') +rememberBox(['ΔЭО ≈ 0 → неполярная; ΔЭО заметна → полярная; ΔЭО велика → ионная.','Более электроотрицательный атом получает δ−.']) +qList(['Полярна ли связь в Cl₂? А в HCl?','Что показывает электроотрицательность?']) +secNav('p37','p39')+readButton('p38'); wireReadBtn('p38'); } @@ -207,6 +209,7 @@ function bp41(){ document.getElementById('p41-body').innerHTML = +'

Молекулярная

лёд, CO₂, I₂
' +'

Металлическая

Fe, Cu, Al
' +'') + +flag('3D-модель кристаллической решётки','Выбери тип решётки и вращай её мышью.','
') +rememberBox(['Ионная и атомная решётки — прочные, тугоплавкие.','Молекулярная — летучая, легкоплавкая.','Тип решётки ← тип связи → свойства.']) +qList(['Какой тип решётки у льда? Почему он легкоплавкий?','Чем объясняется твёрдость алмаза?']) +secNav('p40','final1')+readButton('p41'); wireReadBtn('p41'); }