From f6205621243334f205fff01c73b290b73730bd70 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 19:35:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(chemistry7):=20=D0=B2=D0=B8=D0=B7=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=BF=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D0=B9=D0=B4=20V0=20(=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA?= =?UTF-8?q?)=20+=20=D0=BF=D0=B8=D0=BB=D0=BE=D1=82=20V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chem7_anim.js — анимационный движок (window.Chem7Anim): RAF-цикл с паузой вне экрана (IntersectionObserver), prefers-reduced-motion, headless-guard (jsdom-safe: молекулы на SVG, canvas без getContext в тестах), molecule3d (вращающаяся 3D-модель, drag), separation (частицы: фильтр/выпаривание/магнит/отстаивание/перегонка), colorMorph, confettiSmall. Пилот в Главе 1: - §5/§6: статичные галереи → вращающиеся 3D-модели (H2/O2/O3/N2, H2O/CO2/CH4/NH3) с переключателем; - §2/ПР1: при верном методе разделения проигрывается анимация частиц. Тесты chem7: 16/16 pass; полный прогон 162/165 (3 — baseline Auth). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/chemistry7-page.test.js | 16 ++ frontend/js/chem7_anim.js | 210 ++++++++++++++++++++++++ frontend/js/chem7_ch1_widgets.js | 76 +++++---- frontend/textbooks/chemistry_7_ch1.html | 1 + 4 files changed, 274 insertions(+), 29 deletions(-) create mode 100644 frontend/js/chem7_anim.js diff --git a/backend/tests/chemistry7-page.test.js b/backend/tests/chemistry7-page.test.js index 5442ed3..37be400 100644 --- a/backend/tests/chemistry7-page.test.js +++ b/backend/tests/chemistry7-page.test.js @@ -23,6 +23,7 @@ function buildPage(file) { '/js/biochem-core.js': readF('frontend/js/biochem-core.js'), '/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'), '/js/chem7_svg.js': readF('frontend/js/chem7_svg.js'), + '/js/chem7_anim.js': readF('frontend/js/chem7_anim.js'), '/js/chem7_ch1_widgets.js': readF('frontend/js/chem7_ch1_widgets.js'), '/js/chem7_ch2_widgets.js': readF('frontend/js/chem7_ch2_widgets.js'), '/js/chem7_ch3_widgets.js': readF('frontend/js/chem7_ch3_widgets.js'), @@ -98,6 +99,21 @@ test('ch1 Волна 2: интерактивы §4–§6 монтируются assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); }); +test('ch1 V-пилот: 3D-молекулы §5/§6 + анимация разделения §2', async () => { + const { doc, errors } = await loadDom('chemistry_7_ch1.html'); + doc.defaultView.goTo('p5'); await wait(120); + assert.ok(doc.querySelector('#p5-gal .mv-b'), 'переключатель молекул §5'); + assert.ok(doc.querySelector('#p5-gal-stage svg circle'), '3D-молекула §5 (SVG)'); + doc.defaultView.goTo('p6'); await wait(120); + assert.ok(doc.querySelector('#p6-gal-stage svg circle'), '3D-молекула §6 (SVG)'); + doc.defaultView.goTo('p2'); await wait(120); + const btn = [...doc.querySelectorAll('#p2-sep .c7-m')].find(b => b.dataset.m === 'Фильтрование'); + assert.ok(btn, 'кнопка верного метода §2 найдена'); + btn.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(50); + assert.ok(doc.querySelector('#p2-sep-anim canvas'), 'сцена разделения §2 (canvas)'); + assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); +}); + test('ch1 Волна 3: интерактивы §7–§9 монтируются и считают', async () => { const { doc, errors } = await loadDom('chemistry_7_ch1.html'); doc.defaultView.goTo('p7'); await wait(100); diff --git a/frontend/js/chem7_anim.js b/frontend/js/chem7_anim.js new file mode 100644 index 0000000..7d2e540 --- /dev/null +++ b/frontend/js/chem7_anim.js @@ -0,0 +1,210 @@ +/* chem7_anim.js — анимационный движок флагманов учебника «Химия 7». + * Неймспейс window.Chem7Anim. Используется виджетами chem7_chN_widgets.js. + * + * Принципы (см. plans/textbooks-7/PLAN_CHEMISTRY_7_VISUAL.md): + * - один RAF-цикл на флагман, пауза вне вьюпорта (IntersectionObserver), stop() при уходе; + * - prefers-reduced-motion → статичный кадр вместо цикла; + * - тёмная тема через CSS-переменные; без эмодзи; + * - молекулы — SVG (надёжно в jsdom); частицы/пламя/пузырьки — canvas, но в headless + * (jsdom-тесты) getContext НЕ вызывается, строится только DOM-каркас. + */ +(function (W) { + 'use strict'; + var D = W.document; + var HEADLESS = (typeof navigator !== 'undefined' && /jsdom|HeadlessChrome/i.test(navigator.userAgent || '')); + function reduced() { + try { return !!(W.matchMedia && W.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { return false; } + } + function ease(t) { return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } + function now() { try { return W.performance && W.performance.now ? W.performance.now() : Date.now(); } catch (e) { return Date.now(); } } + function rand(a, b) { return a + Math.random() * (b - a); } + + /* наблюдатель видимости (пауза вне экрана); в jsdom IO нет → всегда видим */ + function observeVisible(host, cb) { + if (typeof IntersectionObserver === 'undefined') { cb(true); return { disconnect: function () {} }; } + var io = new IntersectionObserver(function (es) { cb(es[0] && es[0].isIntersecting); }, { threshold: 0.01 }); + io.observe(host); return io; + } + + /* RAF-цикл с паузой вне экрана. step(dt, tSec). Возвращает {stop()}. */ + function loop(host, step, opts) { + opts = opts || {}; + var raf = 0, running = true, visible = true, last = now(), t0 = last; + if (reduced() || HEADLESS) { try { step(0, 0); } catch (e) {} return { stop: function () {} }; } + var io = observeVisible(host, function (v) { visible = v; }); + function frame() { + if (!running) return; + var t = now(), dt = Math.min(0.05, (t - last) / 1000); last = t; + if (visible) { try { step(dt, (t - t0) / 1000); } catch (e) { running = false; return; } } + raf = W.requestAnimationFrame(frame); + } + raf = W.requestAnimationFrame(frame); + return { stop: function () { running = false; try { W.cancelAnimationFrame(raf); } catch (e) {} try { io.disconnect(); } catch (e) {} } }; + } + + /* создать canvas в host; ctx=null в headless (getContext не зовём) */ + function sceneCanvas(host, w, h) { + host.innerHTML = ''; + var cv = D.createElement('canvas'); + var dpr = HEADLESS ? 1 : (W.devicePixelRatio || 1); + cv.width = w * dpr; cv.height = h * dpr; + cv.style.width = '100%'; cv.style.maxWidth = w + 'px'; cv.style.height = 'auto'; + cv.style.borderRadius = '12px'; cv.style.display = 'block'; + host.appendChild(cv); + var ctx = null; + if (!HEADLESS) { try { ctx = cv.getContext('2d'); if (ctx) ctx.scale(dpr, dpr); } catch (e) { ctx = null; } } + return { cv: cv, ctx: ctx, w: w, h: h }; + } + + function cssVar(name, fallback) { + try { var v = getComputedStyle(D.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } + } + + /* ---- 3D-молекула (SVG, depth-sorted, авто-вращение + drag) ---- */ + var ELEM = { + H: { c: '#e2e8f0', r: 0.32 }, O: { c: '#ef4444', r: 0.46 }, N: { c: '#3b82f6', r: 0.45 }, + C: { c: '#334155', r: 0.46 }, S: { c: '#eab308', r: 0.52 }, Cl: { c: '#22c55e', r: 0.5 } + }; + function molecule3d(host, spec) { + var W0 = spec.size || 240, H0 = spec.size || 200, K = (spec.scale || 52); + var ns = 'http://www.w3.org/2000/svg'; + host.innerHTML = ''; + var svg = D.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 ' + W0 + ' ' + H0); + svg.setAttribute('width', '100%'); + svg.style.maxWidth = W0 + 'px'; svg.style.height = 'auto'; svg.style.touchAction = 'none'; svg.style.cursor = 'grab'; + // defs: радиальные градиенты по элементам + var defs = D.createElementNS(ns, 'defs'); var used = {}; + spec.atoms.forEach(function (a) { used[a.el] = 1; }); + Object.keys(used).forEach(function (el) { + var col = (ELEM[el] || { c: '#94a3b8' }).c; + var g = D.createElementNS(ns, 'radialGradient'); + g.setAttribute('id', 'g_' + el); g.setAttribute('cx', '35%'); g.setAttribute('cy', '32%'); g.setAttribute('r', '70%'); + g.innerHTML = ''; + defs.appendChild(g); + }); + svg.appendChild(defs); + var grp = D.createElementNS(ns, 'g'); svg.appendChild(grp); + host.appendChild(svg); + + var ay = spec.startY != null ? spec.startY : 0.5, ax = -0.35; + function render(angY, angX) { + var cy0 = Math.cos(angY), sy0 = Math.sin(angY), cx0 = Math.cos(angX), sx0 = Math.sin(angX); + var pts = spec.atoms.map(function (a, i) { + var x = a.x * cy0 + a.z * sy0, z1 = -a.x * sy0 + a.z * cy0; + var y = a.y * cx0 - z1 * sx0, z = a.y * sx0 + z1 * cx0; + var depth = (z + 2) / 4; // 0..1 + return { i: i, el: a.el, sx: W0 / 2 + x * K, sy: H0 / 2 - y * K, z: z, depth: depth }; + }); + var order = pts.slice().sort(function (p, q) { return p.z - q.z; }); + var html = ''; + // связи (рисуем между центрами, под атомами по глубине ближнего конца) + (spec.bonds || []).forEach(function (b) { + var p = pts[b[0]], q = pts[b[1]]; + html += ''; + }); + order.forEach(function (p) { + var er = (ELEM[p.el] || { r: 0.42 }).r, r = er * K * (0.72 + 0.5 * p.depth); + html += ''; + if (r > 9) html += '' + p.el + ''; + }); + grp.innerHTML = html; + } + render(ay, ax); + // drag-вращение (без setPointerCapture — правило проекта) + var dragging = false, lx = 0, ly = 0; + function down(e) { dragging = true; svg.style.cursor = 'grabbing'; var p = pt(e); lx = p.x; ly = p.y; } + function move(e) { if (!dragging) return; var p = pt(e); ay += (p.x - lx) * 0.01; ax += (p.y - ly) * 0.01; lx = p.x; ly = p.y; render(ay, ax); } + function up() { dragging = false; svg.style.cursor = 'grab'; } + function pt(e) { var t = e.touches && e.touches[0] ? e.touches[0] : e; return { x: t.clientX || 0, y: t.clientY || 0 }; } + svg.addEventListener('pointerdown', down); + W.addEventListener('pointermove', move); W.addEventListener('pointerup', up); + var h = loop(host, function (dt) { if (!dragging) { ay += dt * 0.6; render(ay, ax); } }); + return { stop: function () { h.stop(); try { W.removeEventListener('pointermove', move); W.removeEventListener('pointerup', up); } catch (e) {} } }; + } + + /* ---- сцена разделения смесей (canvas) ---- */ + function separation(host, kind) { + var w = 300, h = 200, sc = sceneCanvas(host, w, h), ctx = sc.ctx; + if (!ctx) return { stop: function () {} }; // headless: только каркас + var P = []; + function reset() { + P = []; + if (kind === 'magnet') { + for (var i = 0; i < 60; i++) P.push({ x: rand(40, 260), y: rand(40, 150), iron: i % 2 === 0, vx: 0, vy: 0 }); + } else if (kind === 'filter') { + for (var j = 0; j < 50; j++) P.push({ x: rand(120, 180), y: rand(10, 60), sand: j % 2 === 0, vy: rand(20, 50), settled: false }); + } else if (kind === 'evaporate') { + for (var k = 0; k < 28; k++) P.push({ x: rand(90, 210), y: rand(120, 150), vy: -rand(12, 26), life: rand(0, 1) }); + } else if (kind === 'settle') { + for (var m = 0; m < 60; m++) P.push({ x: rand(70, 230), y: rand(60, 150), oil: m % 2 === 0, vy: 0 }); + } else { // distill + for (var n = 0; n < 24; n++) P.push({ x: rand(60, 90), y: rand(120, 150), vy: -rand(14, 26), phase: 0 }); + } + } + reset(); + var pri = cssVar('--pri', '#059669'); + function draw(dt, t) { + ctx.clearRect(0, 0, w, h); + if (kind === 'filter') { + ctx.strokeStyle = '#94a3b8'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(110, 30); ctx.lineTo(150, 110); ctx.lineTo(190, 30); ctx.stroke(); // воронка + ctx.fillStyle = '#bfdbfe'; ctx.fillRect(120, 165, 60, 28); // стакан с водой + P.forEach(function (p) { + if (p.sand) { if (p.y < 100) p.y += p.vy * dt; ctx.fillStyle = '#a16207'; } + else { p.y += p.vy * dt; if (p.y > 200) { p.y = 165; } ctx.fillStyle = '#3b82f6'; } + ctx.beginPath(); ctx.arc(p.x, p.y, p.sand ? 2.5 : 2, 0, 7); ctx.fill(); + }); + } else if (kind === 'evaporate') { + ctx.fillStyle = '#fde68a'; ctx.beginPath(); ctx.ellipse(150, 150, 70, 16, 0, 0, 7); ctx.fill(); // чашка + // кристаллы соли растут + var grow = Math.min(1, t / 6); + for (var i = 0; i < 10; i++) { var s = 2 + grow * 5; ctx.fillStyle = '#e5e7eb'; ctx.fillRect(110 + i * 8, 150 - s, s, s); } + P.forEach(function (p) { p.y += p.vy * dt; p.life += dt * 0.4; if (p.y < 20 || p.life > 1) { p.y = rand(140, 150); p.x = rand(100, 200); p.life = 0; } + ctx.fillStyle = 'rgba(203,213,225,' + (0.6 * (1 - p.life)).toFixed(2) + ')'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); }); + } else if (kind === 'magnet') { + ctx.fillStyle = '#475569'; ctx.fillRect(135, 6, 30, 18); ctx.fillStyle = '#dc2626'; ctx.fillRect(135, 6, 15, 18); // магнит + P.forEach(function (p) { + if (p.iron) { var dx = 150 - p.x, dy = 22 - p.y, d = Math.hypot(dx, dy) || 1; p.x += dx / d * 70 * dt; p.y += dy / d * 70 * dt; ctx.fillStyle = '#475569'; } + else { ctx.fillStyle = '#eab308'; } + ctx.beginPath(); ctx.arc(p.x, p.y, 2.6, 0, 7); ctx.fill(); + }); + } else if (kind === 'settle') { + P.forEach(function (p) { var target = p.oil ? 80 : 150; p.y += (target - p.y) * Math.min(1, dt * 1.5); ctx.fillStyle = p.oil ? '#fbbf24' : '#3b82f6'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); }); + } else { + ctx.fillStyle = '#94a3b8'; ctx.fillRect(40, 150, 60, 10); ctx.fillRect(210, 150, 60, 30); // колба и приёмник + P.forEach(function (p) { if (p.phase === 0) { p.y += p.vy * dt; if (p.y < 40) { p.phase = 1; } } else { p.x += 60 * dt; p.y += 30 * dt; if (p.x > 240) { p.x = rand(60, 90); p.y = rand(120, 150); p.phase = 0; } } + ctx.fillStyle = p.phase === 0 ? 'rgba(148,163,184,.7)' : '#3b82f6'; ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, 7); ctx.fill(); }); + } + } + return loop(host, draw); + } + + /* ---- плавная смена цвета (индикаторы, осадки) ---- */ + function colorMorph(el, toColor, ms) { + if (!el) return; ms = ms || 600; + el.style.transition = 'background-color ' + ms + 'ms ease, color ' + ms + 'ms ease'; + el.style.backgroundColor = toColor; + } + + /* ---- лёгкое конфетти (SVG, без CDN) ---- */ + function confettiSmall(host) { + if (HEADLESS || reduced() || !host) return; + var cols = ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa']; + var box = D.createElement('div'); box.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden'; + if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; + host.appendChild(box); + for (var i = 0; i < 18; i++) { + var s = D.createElement('div'); + s.style.cssText = 'position:absolute;top:-8px;left:' + rand(5, 95) + '%;width:7px;height:7px;border-radius:2px;background:' + cols[i % cols.length] + ';opacity:.9;transition:transform 1.1s cubic-bezier(.3,.7,.3,1),opacity 1.1s'; + box.appendChild(s); + (function (el) { W.requestAnimationFrame(function () { el.style.transform = 'translateY(' + rand(120, 220) + 'px) rotate(' + rand(180, 540) + 'deg)'; el.style.opacity = '0'; }); })(s); + } + setTimeout(function () { try { host.removeChild(box); } catch (e) {} }, 1300); + } + + W.Chem7Anim = { + HEADLESS: HEADLESS, reduced: reduced, ease: ease, loop: loop, sceneCanvas: sceneCanvas, + molecule3d: molecule3d, separation: separation, colorMorph: colorMorph, confettiSmall: confettiSmall, observeVisible: observeVisible + }; +})(window); diff --git a/frontend/js/chem7_ch1_widgets.js b/frontend/js/chem7_ch1_widgets.js index a9776d6..b5e435d 100644 --- a/frontend/js/chem7_ch1_widgets.js +++ b/frontend/js/chem7_ch1_widgets.js @@ -67,17 +67,19 @@ /* §2 / ПР1 — разделитель смесей: выбери метод для смеси */ var MIX = [ - { mix: 'Песок и вода', method: 'Фильтрование', why: 'Песок не растворяется — задерживается фильтром, вода проходит.' }, - { mix: 'Соль и вода', method: 'Выпаривание', why: 'Вода испаряется, соль остаётся на дне.' }, - { mix: 'Железные опилки и сера', method: 'Магнит', why: 'Железо притягивается магнитом, сера — нет.' }, - { mix: 'Вода и растительное масло', method: 'Отстаивание (делительная воронка)', why: 'Масло легче воды и не смешивается — слои разделяют.' }, - { mix: 'Спирт и вода', method: 'Перегонка (дистилляция)', why: 'У спирта и воды разные температуры кипения.' } + { mix: 'Песок и вода', method: 'Фильтрование', kind: 'filter', why: 'Песок не растворяется — задерживается фильтром, вода проходит.' }, + { mix: 'Соль и вода', method: 'Выпаривание', kind: 'evaporate', why: 'Вода испаряется, соль остаётся на дне.' }, + { mix: 'Железные опилки и сера', method: 'Магнит', kind: 'magnet', why: 'Железо притягивается магнитом, сера — нет.' }, + { mix: 'Вода и растительное масло', method: 'Отстаивание (делительная воронка)', kind: 'settle', why: 'Масло легче воды и не смешивается — слои разделяют.' }, + { mix: 'Спирт и вода', method: 'Перегонка (дистилляция)', kind: 'distill', why: 'У спирта и воды разные температуры кипения.' } ]; var METHODS = ['Фильтрование', 'Выпаривание', 'Магнит', 'Отстаивание (делительная воронка)', 'Перегонка (дистилляция)']; function mount_sep(mountId) { var m = $(mountId); if (!m || m._built) return; m._built = 1; - var idx = 0; + var idx = 0, anim = null; + function stopAnim() { if (anim) { anim.stop(); anim = null; } } function render() { + stopAnim(); var cur = MIX[idx]; m.innerHTML = '
' @@ -85,8 +87,9 @@ + '
' + METHODS.map(function (mt) { return ''; }).join('') + '
' - + '
Выбери способ разделения.
'; - $(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; m._built = 0; render(); }); + + '
Выбери способ разделения — при верном ответе увидишь анимацию.
' + + '
'; + $(mountId + '-pick').addEventListener('change', function (e) { idx = +e.target.value; render(); }); var out = $(mountId + '-out'); m.querySelectorAll('.c7-m').forEach(function (b) { b.addEventListener('click', function () { @@ -95,6 +98,10 @@ out.innerHTML = ok ? 'Верно! ' + esc(cur.method) + '. ' + esc(cur.why) : 'Не подходит. Подумай, чем различаются вещества в смеси (растворимость, магнитные свойства, температура кипения, плотность).'; + stopAnim(); + var host = $(mountId + '-anim'); + if (ok && W.Chem7Anim && host) anim = W.Chem7Anim.separation(host, cur.kind); + else if (host) host.innerHTML = ''; }); }); } @@ -201,19 +208,39 @@ +'
'+esc(note)+'
'; } - /* §5 — галерея простых веществ */ - function mount_p5() { - var m = $('p5-gal'); if (!m || m._built) return; m._built = 1; - m.innerHTML = '
' - + molCard('Водород','H2',[['H',2]],'2 атома H — двухатомная молекула') - + molCard('Кислород','O2',[['O',2]],'2 атома O') - + molCard('Озон','O3',[['O',3]],'3 атома O — тоже простое вещество') - + molCard('Азот','N2',[['N',2]],'2 атома N') - + '
Во всех молекулах — атомы одного элемента → это простые вещества. Кислород $\\text{O}_2$ и озон $\\text{O}_3$ образованы одним элементом, но это разные простые вещества.
'; - if (W.chem8RenderMath) try { W.chem8RenderMath(m); } catch(e){} + /* 3D-модели молекул для §5/§6 (через Chem7Anim.molecule3d) */ + var MOL = { + H2: { atoms:[{el:'H',x:-0.7,y:0,z:0},{el:'H',x:0.7,y:0,z:0}], bonds:[[0,1]] }, + O2: { atoms:[{el:'O',x:-0.75,y:0,z:0},{el:'O',x:0.75,y:0,z:0}], bonds:[[0,1]] }, + O3: { atoms:[{el:'O',x:0,y:0.45,z:0},{el:'O',x:-1.05,y:-0.4,z:0},{el:'O',x:1.05,y:-0.4,z:0}], bonds:[[0,1],[0,2]] }, + N2: { atoms:[{el:'N',x:-0.7,y:0,z:0},{el:'N',x:0.7,y:0,z:0}], bonds:[[0,1]] }, + H2O: { atoms:[{el:'O',x:0,y:0,z:0},{el:'H',x:-0.78,y:0.6,z:0},{el:'H',x:0.78,y:0.6,z:0}], bonds:[[0,1],[0,2]] }, + CO2: { atoms:[{el:'C',x:0,y:0,z:0},{el:'O',x:-1.15,y:0,z:0},{el:'O',x:1.15,y:0,z:0}], bonds:[[0,1],[0,2]] }, + CH4: { atoms:[{el:'C',x:0,y:0,z:0},{el:'H',x:0.63,y:0.63,z:0.63},{el:'H',x:-0.63,y:-0.63,z:0.63},{el:'H',x:-0.63,y:0.63,z:-0.63},{el:'H',x:0.63,y:-0.63,z:-0.63}], bonds:[[0,1],[0,2],[0,3],[0,4]] }, + NH3: { atoms:[{el:'N',x:0,y:0.32,z:0},{el:'H',x:0.94,y:-0.3,z:0},{el:'H',x:-0.47,y:-0.3,z:0.82},{el:'H',x:-0.47,y:-0.3,z:-0.82}], bonds:[[0,1],[0,2],[0,3]] } + }; + function fmlName(k) { return C().formula ? C().formula(k) : k; } + function molViewer(host, keys, caption) { + if (!host || host._built) return; host._built = 1; + var A = W.Chem7Anim; + if (!A || !A.molecule3d) { host.innerHTML = '
3D-модели недоступны.
'; return; } + var cur = keys[0], handle = null; + function render() { + if (handle) handle.stop(); + host.innerHTML = '
' + + keys.map(function (k) { return ''; }).join('') + '
' + + '
' + + '
' + caption + ' Перетаскивай модель мышью, чтобы повернуть.
'; + handle = A.molecule3d($(host.id + '-stage'), MOL[cur]); + host.querySelectorAll('.mv-b').forEach(function (b) { b.addEventListener('click', function () { cur = b.dataset.k; render(); }); }); + } + render(); } - /* §6 — классификатор простое/сложное + галерея сложных веществ */ + /* §5 — 3D-модели простых веществ */ + function mount_p5() { molViewer($('p5-gal'), ['H2', 'O2', 'O3', 'N2'], 'Простое вещество — атомы одного элемента.'); } + + /* §6 — классификатор простое/сложное + 3D-модели сложных веществ */ function mount_p6() { var c = $('p6-cls'); if (c) classifier(c, { @@ -223,16 +250,7 @@ { t:'N₂', b:0 }, { t:'NH₃', b:1 }, { t:'S', b:0 }, { t:'CH₄', b:1 } ] }); - var g = $('p6-gal'); - if (g && !g._built) { g._built = 1; - g.innerHTML = '
' - + molCard('Вода','H2O',[['O',1],['H',2]],'2 элемента: H и O') - + molCard('Углекислый газ','CO2',[['C',1],['O',2]],'2 элемента: C и O') - + molCard('Метан','CH4',[['C',1],['H',4]],'2 элемента: C и H') - + molCard('Аммиак','NH3',[['N',1],['H',3]],'2 элемента: N и H') - + '
В каждой молекуле — атомы разных элементов → это сложные вещества.
'; - if (W.chem8RenderMath) try { W.chem8RenderMath(g); } catch(e){} - } + molViewer($('p6-gal'), ['H2O', 'CO2', 'CH4', 'NH3'], 'Сложное вещество — атомы разных элементов.'); } /* ── Волна 3 ── */ diff --git a/frontend/textbooks/chemistry_7_ch1.html b/frontend/textbooks/chemistry_7_ch1.html index c6aaa87..65d3604 100644 --- a/frontend/textbooks/chemistry_7_ch1.html +++ b/frontend/textbooks/chemistry_7_ch1.html @@ -23,6 +23,7 @@ html.dark{--bg:#0a1a12;--border:#1f4030;--pri-soft:rgba(5,150,105,.18);--sec-acc +