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>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 19:42:33 +03:00
parent f620562124
commit 41985a93eb
3 changed files with 93 additions and 6 deletions
+8
View File
@@ -111,6 +111,14 @@ test('ch1 V-пилот: 3D-молекулы §5/§6 + анимация разд
assert.ok(btn, 'кнопка верного метода §2 найдена'); assert.ok(btn, 'кнопка верного метода §2 найдена');
btn.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(50); btn.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(50);
assert.ok(doc.querySelector('#p2-sep-anim canvas'), 'сцена разделения §2 (canvas)'); assert.ok(doc.querySelector('#p2-sep-anim canvas'), 'сцена разделения §2 (canvas)');
// §10: анимация признаков реакции после «Провести опыт»
doc.defaultView.goTo('p10'); await wait(120);
doc.getElementById('p10-signs-go').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(40);
assert.ok(doc.querySelector('#p10-signs-stage div'), 'анимация признаков реакции §10');
// §11: осадок появляется при «Смешать»
doc.defaultView.goTo('p11'); await wait(120);
doc.getElementById('p11-mix').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true })); await wait(40);
assert.ok(doc.querySelector('#p11-stage div'), 'анимация осадка §11');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | ')); assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
}); });
+63 -1
View File
@@ -203,8 +203,70 @@
setTimeout(function () { try { host.removeChild(box); } catch (e) {} }, 1300); 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 = { W.Chem7Anim = {
HEADLESS: HEADLESS, reduced: reduced, ease: ease, loop: loop, sceneCanvas: sceneCanvas, HEADLESS: HEADLESS, reduced: reduced, ease: ease, loop: loop, sceneCanvas: sceneCanvas,
molecule3d: molecule3d, separation: separation, colorMorph: colorMorph, confettiSmall: confettiSmall, observeVisible: observeVisible molecule3d: molecule3d, separation: separation, colorMorph: colorMorph, confettiSmall: confettiSmall, observeVisible: observeVisible,
bubbleField: bubbleField, precipField: precipField, flameBox: flameBox, colorBlock: colorBlock
}; };
})(window); })(window);
+22 -5
View File
@@ -325,17 +325,30 @@
{ name: 'Горение серы', signs: ['выделение света и тепла (пламя)', 'появление резкого запаха'] }, { name: 'Горение серы', signs: ['выделение света и тепла (пламя)', 'появление резкого запаха'] },
{ name: 'Добавление соды в уксус', signs: ['выделение газа (пузырьки)'] } { name: 'Добавление соды в уксус', signs: ['выделение газа (пузырьки)'] }
]; ];
// анимация на каждый опыт (через Chem7Anim, CSS-хелперы)
function demoAnim(idx, host) {
var A = W.Chem7Anim; if (!A || !host) return null;
if (idx === 0) return A.colorBlock(host, '#16a34a', '#1f2937', 'малахит → CuO + газы', 2000); // зелёный → чёрный
if (idx === 1) return A.precipField(host, { color: '#38bdf8' }); // голубой осадок
if (idx === 2) return A.flameBox(host, { color: '#3b82f6', sparks: true }); // синее пламя серы
return A.bubbleField(host, { color: 'rgba(255,255,255,.85)' }); // пузырьки газа
}
function mount_signs(mountId) { function mount_signs(mountId) {
var m = $(mountId); if (!m || m._built) return; m._built = 1; 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() { function render() {
stopAnim();
m.innerHTML = '<div class="fld"><label>Опыт</label><select id="' + mountId + '-pick">' m.innerHTML = '<div class="fld"><label>Опыт</label><select id="' + mountId + '-pick">'
+ DEMOS.map(function (d, i) { return '<option value="' + i + '"' + (i === idx ? ' selected' : '') + '>' + esc(d.name) + '</option>'; }).join('') + '</select>' + DEMOS.map(function (d, i) { return '<option value="' + i + '"' + (i === idx ? ' selected' : '') + '>' + esc(d.name) + '</option>'; }).join('') + '</select>'
+ '<button class="btn primary" id="' + mountId + '-go">Провести опыт</button></div>' + '<button class="btn primary" id="' + mountId + '-go">Провести опыт</button></div>'
+ '<div id="' + mountId + '-stage" style="margin:8px 0"></div>'
+ '<div class="out" id="' + mountId + '-out">Выбери опыт и нажми «Провести опыт».</div>'; + '<div class="out" id="' + mountId + '-out">Выбери опыт и нажми «Провести опыт».</div>';
$(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(); });
$(mountId + '-go').addEventListener('click', function () { $(mountId + '-go').addEventListener('click', function () {
var d = DEMOS[idx], out = $(mountId + '-out'); out.className = 'out ok'; var d = DEMOS[idx], out = $(mountId + '-out');
stopAnim(); anim = demoAnim(idx, $(mountId + '-stage'));
out.className = 'out ok';
out.innerHTML = '<b>Наблюдаемые признаки реакции:</b><div style="margin-top:6px">' out.innerHTML = '<b>Наблюдаемые признаки реакции:</b><div style="margin-top:6px">'
+ d.signs.map(function (s) { return '<div style="padding:5px 10px;margin:3px 0;border-radius:8px;background:var(--pri-soft);font-weight:600">&#10003; ' + esc(s) + '</div>'; }).join('') + d.signs.map(function (s) { return '<div style="padding:5px 10px;margin:3px 0;border-radius:8px;background:var(--pri-soft);font-weight:600">&#10003; ' + esc(s) + '</div>'; }).join('')
+ '</div><div style="font-size:.84rem;color:var(--muted);margin-top:6px">Эти признаки указывают, что произошла <b>химическая реакция</b> — образовались новые вещества.</div>'; + '</div><div style="font-size:.84rem;color:var(--muted);margin-top:6px">Эти признаки указывают, что произошла <b>химическая реакция</b> — образовались новые вещества.</div>';
@@ -366,13 +379,17 @@
+ '<text x="225" y="108" font-size="11" fill="var(--muted)">' + (mixed ? 'продукты' : 'реагенты') + '</text>' + '<text x="225" y="108" font-size="11" fill="var(--muted)">' + (mixed ? 'продукты' : 'реагенты') + '</text>'
+ '</svg>'; + '</svg>';
} }
var anim = null;
function render() { function render() {
if (anim) { anim.stop(); anim = null; }
m.innerHTML = scale() m.innerHTML = scale()
+ '<div style="margin:6px 0;font-size:.92rem">' + (mixed + '<div style="margin:6px 0;font-size:.92rem">' + (mixed
? 'После реакции: <b>осадок Cu(OH)₂ + раствор Na₂SO₄</b>. Стрелка весов не сдвинулась — <b>масса сохранилась</b> (100 г = 100 г).' ? 'После реакции: <b>осадок Cu(OH)₂ + раствор Na₂SO₄</b>. Стрелка весов <b>не сдвинулась</b> — масса сохранилась (100 г = 100 г).'
: 'До реакции: <b>раствор CuSO₄ + раствор NaOH</b>, общая масса 100 г.') + '</div>' : 'До реакции: <b>раствор CuSO₄ + раствор NaOH</b>, общая масса 100 г.') + '</div>'
+ '<div id="p11-stage" style="margin:6px 0"></div>'
+ '<button class="btn primary" id="p11-mix">' + (mixed ? 'Сбросить' : 'Смешать растворы') + '</button>'; + '<button class="btn primary" id="p11-mix">' + (mixed ? 'Сбросить' : 'Смешать растворы') + '</button>';
$('p11-mix').addEventListener('click', function () { mixed = !mixed; m._built = 0; render(); }); if (mixed && W.Chem7Anim) anim = W.Chem7Anim.precipField($('p11-stage'), { color: '#38bdf8', h: 96 });
$('p11-mix').addEventListener('click', function () { mixed = !mixed; render(); });
} }
render(); render();
} }