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:
@@ -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(' | '));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">✓ ' + 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">✓ ' + 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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user