Files
Learn_System/frontend/js/chem7_anim.js
T
Maxim Dolgolyov 41985a93eb feat(chemistry7): визуал V1 — анимация §10 (признаки реакции) и §11 (осадок)
chem7_anim.js: CSS-хелперы (jsdom-safe, без canvas) — bubbleField (пузырьки
газа), precipField (падающий осадок + слой), flameBox (мерцающее пламя+искры),
colorBlock (плавная смена цвета вещества).
§10/ЛО1: «Провести опыт» проигрывает анимацию по типу опыта (малахит
зеленеет→чернеет, голубой осадок CuSO4+NaOH, синее пламя серы, пузырьки CO2).
§11: при «Смешать» формируется осадок Cu(OH)2, весы остаются ровными.

Тесты 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:42:33 +03:00

273 lines
18 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);
}
/* ---- CSS-анимации (jsdom-safe, без canvas): пузырьки, осадок, пламя, смена цвета ---- */
function injectKeyframes() {
if (D.getElementById('chem7-kf')) return;
var st = D.createElement('style'); st.id = 'chem7-kf';
st.textContent =
'@keyframes c7-rise{0%{transform:translateY(0) scale(.6);opacity:0}15%{opacity:.9}100%{transform:translateY(-92px) scale(1);opacity:0}}'
+ '@keyframes c7-fall{0%{transform:translateY(-26px);opacity:0}18%{opacity:1}100%{transform:translateY(58px);opacity:.85}}'
+ '@keyframes c7-flick{0%,100%{transform:scaleY(1);opacity:.92}50%{transform:scaleY(1.18) translateY(-3px);opacity:1}}';
(D.head || D.documentElement).appendChild(st);
}
function fieldHost(host, h) {
host.innerHTML = ''; host.style.position = 'relative'; host.style.height = h + 'px';
host.style.overflow = 'hidden'; host.style.borderRadius = '12px';
return host;
}
// поток пузырьков газа снизу вверх
function bubbleField(host, opts) {
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
host.style.background = opts.bg || 'linear-gradient(180deg,var(--pri-soft),transparent)';
var n = opts.count || 14, col = opts.color || 'rgba(255,255,255,.85)';
for (var i = 0; i < n; i++) {
var d = D.createElement('div'), sz = rand(5, 11);
d.style.cssText = 'position:absolute;bottom:6px;left:' + rand(8, 92) + '%;width:' + sz + 'px;height:' + sz + 'px;border-radius:50%;background:' + col + ';border:1px solid rgba(0,0,0,.12);animation:c7-rise ' + rand(1.3, 2.4).toFixed(2) + 's linear ' + rand(0, 1.6).toFixed(2) + 's infinite';
host.appendChild(d);
}
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
}
// осадок: частицы падают и оседают слоем
function precipField(host, opts) {
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
host.style.background = opts.bg || 'linear-gradient(180deg,transparent,var(--pri-soft))';
var n = opts.count || 16, col = opts.color || '#38bdf8';
var sed = D.createElement('div'); sed.style.cssText = 'position:absolute;left:0;right:0;bottom:0;height:14px;background:' + col + ';opacity:.55;border-radius:0 0 12px 12px'; host.appendChild(sed);
for (var i = 0; i < n; i++) {
var d = D.createElement('div'), sz = rand(5, 9);
d.style.cssText = 'position:absolute;top:8px;left:' + rand(8, 92) + '%;width:' + sz + 'px;height:' + sz + 'px;border-radius:50%;background:' + col + ';animation:c7-fall ' + rand(1.1, 2.0).toFixed(2) + 's ease-in ' + rand(0, 1.4).toFixed(2) + 's infinite';
host.appendChild(d);
}
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
}
// пламя (мерцающая капля-градиент)
function flameBox(host, opts) {
opts = opts || {}; injectKeyframes(); fieldHost(host, opts.h || 120);
var col = opts.color || '#f97316';
var f = D.createElement('div');
f.style.cssText = 'position:absolute;left:50%;bottom:10px;transform:translateX(-50%);transform-origin:bottom center;width:46px;height:78px;border-radius:50% 50% 50% 50%/60% 60% 40% 40%;background:radial-gradient(circle at 50% 75%,#fde047,' + col + ' 60%,transparent 78%);animation:c7-flick .5s ease-in-out infinite';
host.appendChild(f);
if (opts.sparks) for (var i = 0; i < 8; i++) { var s = D.createElement('div'); s.style.cssText = 'position:absolute;bottom:14px;left:' + rand(38, 62) + '%;width:3px;height:3px;border-radius:50%;background:#fb923c;animation:c7-rise ' + rand(.8, 1.4).toFixed(2) + 's linear ' + rand(0, 1).toFixed(2) + 's infinite'; host.appendChild(s); }
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} } };
}
// блок вещества с плавной сменой цвета (зелёный→чёрный и т.п.)
function colorBlock(host, fromC, toC, label, ms) {
fieldHost(host, 90); ms = ms || 1800;
var b = D.createElement('div');
b.style.cssText = 'position:absolute;inset:14px;border-radius:10px;background:' + fromC + ';transition:background ' + ms + 'ms ease;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;text-shadow:0 1px 2px rgba(0,0,0,.4)';
b.textContent = label || '';
host.appendChild(b);
W.requestAnimationFrame(function () { W.requestAnimationFrame(function () { b.style.background = toC; }); });
return { stop: function () { try { host.innerHTML = ''; host.style.height = ''; } catch (e) {} }, el: b };
}
W.Chem7Anim = {
HEADLESS: HEADLESS, reduced: reduced, ease: ease, loop: loop, sceneCanvas: sceneCanvas,
molecule3d: molecule3d, separation: separation, colorMorph: colorMorph, confettiSmall: confettiSmall, observeVisible: observeVisible,
bubbleField: bubbleField, precipField: precipField, flameBox: flameBox, colorBlock: colorBlock
};
})(window);