Files
Learn_System/frontend/js/chem7_anim.js
T
Maxim Dolgolyov f620562124 feat(chemistry7): визуальный апгрейд V0 (движок) + пилот V1
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) <noreply@anthropic.com>
2026-05-30 19:35:44 +03:00

211 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = '<stop offset="0%" stop-color="#fff" stop-opacity="0.92"/><stop offset="38%" stop-color="' + col + '"/><stop offset="100%" stop-color="' + col + '" stop-opacity="0.78"/>';
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 += '<line x1="' + p.sx.toFixed(1) + '" y1="' + p.sy.toFixed(1) + '" x2="' + q.sx.toFixed(1) + '" y2="' + q.sy.toFixed(1) + '" stroke="#94a3b8" stroke-width="' + (5 + 3 * (p.depth + q.depth) / 2).toFixed(1) + '" stroke-linecap="round" opacity="0.55"/>';
});
order.forEach(function (p) {
var er = (ELEM[p.el] || { r: 0.42 }).r, r = er * K * (0.72 + 0.5 * p.depth);
html += '<circle cx="' + p.sx.toFixed(1) + '" cy="' + p.sy.toFixed(1) + '" r="' + r.toFixed(1) + '" fill="url(#g_' + p.el + ')" opacity="' + (0.6 + 0.4 * p.depth).toFixed(2) + '"/>';
if (r > 9) html += '<text x="' + p.sx.toFixed(1) + '" y="' + (p.sy + r * 0.32).toFixed(1) + '" text-anchor="middle" font-size="' + (r * 0.85).toFixed(1) + '" font-weight="700" fill="#fff" opacity="' + (0.5 + 0.5 * p.depth).toFixed(2) + '">' + p.el + '</text>';
});
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);